From a9ce5563b10bebe60d3c7d37a758d23632c5587f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:14 +0800 Subject: [PATCH 01/19] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E4=B8=8E=E5=B7=A5=E7=A8=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 17 +- apps/admin/public/favicon.svg | 5 +- apps/admin/vite.config.ts | 8 +- apps/admin/vitest.config.ts | 3 +- apps/server/package.json | 14 +- apps/server/tsconfig.json | 1 + package-lock.json | 1424 +++++++++++++++++++-------------- package.json | 18 +- serve-proxy.js | 2 +- 9 files changed, 853 insertions(+), 639 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index dafe6c5..22de786 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -21,28 +21,37 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@rc-component/upload": "^1.1.1", + "@tanstack/react-query": "^5.101.4", "antd": "^6.3.6", "axios": "^1.15.1", "dayjs": "^1.11.20", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", - "lucide-react": "^0.468.0", + "fast-deep-equal": "^3.1.3", + "file-saver": "^2.0.5", + "mermaid": "^11.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.1", - "tslib": "^2.8.1", + "react-router": "^8.3.0", + "use-immer": "^0.11.0", + "usehooks-ts": "^3.1.1", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { "@gongxue/typescript-config": "*", + "@tanstack/react-query-devtools": "^5.101.4", + "@types/file-saver": "^2.0.7", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.1", "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", "playwright": "^1.61.1", + "rollup-plugin-visualizer": "^7.0.1", "typescript": "~6.0.2", "vite": "^8.0.9", "vitest": "^4.1.10" diff --git a/apps/admin/public/favicon.svg b/apps/admin/public/favicon.svg index 6893eb1..00701af 100644 --- a/apps/admin/public/favicon.svg +++ b/apps/admin/public/favicon.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index d791226..96fc050 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -1,9 +1,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { visualizer } from 'rollup-plugin-visualizer'; // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + ...(process.env.BUNDLE_VISUALIZE + ? [visualizer({ filename: 'dist/stats.json', template: 'raw-data', gzipSize: true })] + : []), + ], server: { port: 3002, proxy: { diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts index bfa0a10..2aea384 100644 --- a/apps/admin/vitest.config.ts +++ b/apps/admin/vitest.config.ts @@ -6,7 +6,7 @@ import { playwright } from '@vitest/browser-playwright'; export default defineConfig({ plugins: [react()], optimizeDeps: { - include: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'], + include: ['react', 'react-dom', 'react-dom/client', 'react-router'], }, resolve: { alias: { @@ -37,7 +37,6 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.test.*', 'src/**/*.spec.*'], }, - // Setup file for global test helpers setupFiles: ['./src/test/setup.ts'], }, }); diff --git a/apps/server/package.json b/apps/server/package.json index b0bd593..1069ea5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -46,16 +46,21 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "echarts": "^6.1.0", + "compression": "^1.8.1", + "dotenv": "^17.4.1", "exceljs": "^4.4.0", + "express": "^5.2.1", + "helmet": "^8.3.0", + "jszip": "^3.10.1", "mammoth": "^1.12.0", "multer": "^2.2.0", "mysql2": "^3.22.2", + "nestjs-pino": "^4.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "pdf-parse": "^2.4.5", "pdfkit": "^0.18.0", + "pino": "^10.3.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.31" @@ -64,28 +69,23 @@ "better-sqlite3": "^12.9.0" }, "devDependencies": { - "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@gongxue/typescript-config": "*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", - "@types/passport-local": "^1.0.38", "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", - "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "~6.0.2", diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index f27dfe0..c90b57c 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./", + "types": ["node", "jest"], "ignoreDeprecations": "6.0" } } diff --git a/package-lock.json b/package-lock.json index ba0e295..d061824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,6 @@ "apps/*", "packages/*" ], - "dependencies": { - "@fission-ai/openspec": "^1.5.0" - }, "devDependencies": { "oxfmt": "^0.57.0", "oxlint": "^1.72.0", @@ -31,33 +28,63 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@rc-component/upload": "^1.1.1", + "@tanstack/react-query": "^5.101.4", "antd": "^6.3.6", "axios": "^1.15.1", "dayjs": "^1.11.20", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", - "lucide-react": "^0.468.0", + "fast-deep-equal": "^3.1.3", + "file-saver": "^2.0.5", + "mermaid": "^11.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.1", - "tslib": "^2.8.1", + "react-router": "^8.3.0", + "use-immer": "^0.11.0", + "usehooks-ts": "^3.1.1", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { "@gongxue/typescript-config": "*", + "@tanstack/react-query-devtools": "^5.101.4", + "@types/file-saver": "^2.0.7", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.1", "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", "playwright": "^1.61.1", + "rollup-plugin-visualizer": "^7.0.1", "typescript": "~6.0.2", "vite": "^8.0.9", "vitest": "^4.1.10" } }, + "apps/admin/node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "apps/server": { "name": "@gongxue/server", "version": "0.0.1", @@ -80,43 +107,43 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "echarts": "^6.1.0", + "compression": "^1.8.1", + "dotenv": "^17.4.1", "exceljs": "^4.4.0", + "express": "^5.2.1", + "helmet": "^8.3.0", + "jszip": "^3.10.1", "mammoth": "^1.12.0", "multer": "^2.2.0", "mysql2": "^3.22.2", + "nestjs-pino": "^4.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "pdf-parse": "^2.4.5", "pdfkit": "^0.18.0", + "pino": "^10.3.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.31" }, "devDependencies": { - "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@gongxue/typescript-config": "*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", - "@types/passport-local": "^1.0.38", "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", - "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "~6.0.2", @@ -1504,31 +1531,6 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, - "node_modules/@fission-ai/openspec": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/@fission-ai/openspec/-/openspec-1.5.0.tgz", - "integrity": "sha512-SLZkyF51gFYkISufZKaka0X04z4y/WCjPOcCB+EC7tALd0TC+7V76BOIzWOSIOhdhBWwh5EMIBhrgLnugIh1DA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/prompts": "^7.10.1", - "chalk": "^5.5.0", - "commander": "^14.0.0", - "cross-spawn": "7.0.6", - "fast-glob": "^3.3.3", - "ora": "^8.2.0", - "posthog-node": "^5.20.0", - "yaml": "^2.8.2", - "zod": "^4.0.17" - }, - "bin": { - "openspec": "bin/openspec.js" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@gongxue/admin": { "resolved": "apps/admin", "link": true @@ -1628,6 +1630,7 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/@inquirer/ansi/-/ansi-1.0.2.tgz", "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1637,6 +1640,7 @@ "version": "4.3.2", "resolved": "https://registry.npmmirror.com/@inquirer/checkbox/-/checkbox-4.3.2.tgz", "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/ansi": "^1.0.2", @@ -1661,6 +1665,7 @@ "version": "5.1.21", "resolved": "https://registry.npmmirror.com/@inquirer/confirm/-/confirm-5.1.21.tgz", "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1682,6 +1687,7 @@ "version": "10.3.2", "resolved": "https://registry.npmmirror.com/@inquirer/core/-/core-10.3.2.tgz", "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/ansi": "^1.0.2", @@ -1709,6 +1715,7 @@ "version": "4.2.23", "resolved": "https://registry.npmmirror.com/@inquirer/editor/-/editor-4.2.23.tgz", "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1731,6 +1738,7 @@ "version": "4.0.23", "resolved": "https://registry.npmmirror.com/@inquirer/expand/-/expand-4.0.23.tgz", "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1753,6 +1761,7 @@ "version": "1.0.3", "resolved": "https://registry.npmmirror.com/@inquirer/external-editor/-/external-editor-1.0.3.tgz", "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -1774,6 +1783,7 @@ "version": "1.0.15", "resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.15.tgz", "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1783,6 +1793,7 @@ "version": "4.3.1", "resolved": "https://registry.npmmirror.com/@inquirer/input/-/input-4.3.1.tgz", "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1804,6 +1815,7 @@ "version": "3.0.23", "resolved": "https://registry.npmmirror.com/@inquirer/number/-/number-3.0.23.tgz", "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1825,6 +1837,7 @@ "version": "4.0.23", "resolved": "https://registry.npmmirror.com/@inquirer/password/-/password-4.0.23.tgz", "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/ansi": "^1.0.2", @@ -1847,6 +1860,7 @@ "version": "7.10.1", "resolved": "https://registry.npmmirror.com/@inquirer/prompts/-/prompts-7.10.1.tgz", "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/checkbox": "^4.3.2", @@ -1876,6 +1890,7 @@ "version": "4.1.11", "resolved": "https://registry.npmmirror.com/@inquirer/rawlist/-/rawlist-4.1.11.tgz", "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1898,6 +1913,7 @@ "version": "3.2.2", "resolved": "https://registry.npmmirror.com/@inquirer/search/-/search-3.2.2.tgz", "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.3.2", @@ -1921,6 +1937,7 @@ "version": "4.4.2", "resolved": "https://registry.npmmirror.com/@inquirer/select/-/select-4.4.2.tgz", "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/ansi": "^1.0.2", @@ -1945,6 +1962,7 @@ "version": "3.0.10", "resolved": "https://registry.npmmirror.com/@inquirer/type/-/type-3.0.10.tgz", "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2399,9 +2417,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3833,41 +3851,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@officecli/officecli": { "version": "1.0.143", "resolved": "https://registry.npmmirror.com/@officecli/officecli/-/officecli-1.0.143.tgz", @@ -4556,6 +4539,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4586,21 +4575,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@posthog/core": { - "version": "1.39.3", - "resolved": "https://registry.npmmirror.com/@posthog/core/-/core-1.39.3.tgz", - "integrity": "sha512-oR+B8Q5O61N+W2+HVOBG9dbxAT/+OVxX+XvpNj6KYgptN2EB14JiKQ9Rm7DNpErNTLV7WApZEK/URkZgldOxfg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.392.0" - } - }, - "node_modules/@posthog/types": { - "version": "1.392.0", - "resolved": "https://registry.npmmirror.com/@posthog/types/-/types-1.392.0.tgz", - "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", - "license": "MIT" - }, "node_modules/@rc-component/async-validator": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", @@ -5645,6 +5619,61 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmmirror.com/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz", + "integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz", + "integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -5836,13 +5865,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -6202,6 +6224,13 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -6336,18 +6365,6 @@ "@types/passport-strategy": "*" } }, - "node_modules/@types/passport-local": { - "version": "1.0.38", - "resolved": "https://registry.npmmirror.com/@types/passport-local/-/passport-local-1.0.38.tgz", - "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmmirror.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -6397,6 +6414,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmmirror.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", @@ -6682,16 +6709,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -8047,6 +8074,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -8349,27 +8385,15 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/brotli": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/brotli/-/brotli-1.3.3.tgz", @@ -8507,6 +8531,22 @@ "node": ">=0.2.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", @@ -8637,18 +8677,6 @@ "node": "*" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/char-regex/-/char-regex-1.0.2.tgz", @@ -8693,6 +8721,7 @@ "version": "2.2.0", "resolved": "https://registry.npmmirror.com/chardet/-/chardet-2.2.0.tgz", "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, "license": "MIT" }, "node_modules/chokidar": { @@ -8774,25 +8803,11 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8866,6 +8881,7 @@ "version": "4.1.0", "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, "license": "ISC", "engines": { "node": ">= 12" @@ -9019,15 +9035,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/comment-json": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/comment-json/-/comment-json-5.0.0.tgz", @@ -9067,6 +9074,60 @@ "node": ">= 10" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -9132,6 +9193,12 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -9907,6 +9974,36 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", @@ -9947,6 +10044,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -10244,20 +10354,6 @@ "zrender": "6.1.0" } }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "size-sensor": "^1.0.1" - }, - "peerDependencies": { - "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "react": "^15.0.0 || >=16.0.0" - } - }, "node_modules/echarts/node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", @@ -10294,6 +10390,7 @@ "version": "10.6.0", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -10850,22 +10947,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -10903,15 +10984,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", @@ -10948,6 +11020,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmmirror.com/file-type/-/file-type-21.3.4.tgz", @@ -10973,18 +11051,6 @@ "license": "MIT", "optional": true }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", @@ -11391,6 +11457,7 @@ "version": "1.6.0", "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11484,18 +11551,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -11514,16 +11569,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -11702,6 +11757,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -11885,6 +11952,17 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", @@ -12043,10 +12121,27 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12075,6 +12170,7 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -12093,13 +12189,33 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12111,15 +12227,6 @@ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", @@ -12160,13 +12267,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12485,9 +12596,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -13010,9 +13121,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -13327,6 +13438,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-md5": { "version": "0.8.3", "resolved": "https://registry.npmmirror.com/js-md5/-/js-md5-0.8.3.tgz", @@ -14036,6 +14159,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -14165,34 +14294,6 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", @@ -14249,15 +14350,6 @@ "url": "https://github.com/sponsors/wellwelwel" } }, - "node_modules/lucide-react": { - "version": "0.468.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", - "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", @@ -14430,18 +14522,9 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/mermaid": { "version": "11.16.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz", "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", "license": "MIT", "dependencies": { @@ -14503,19 +14586,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", @@ -14564,18 +14634,6 @@ "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", @@ -14895,6 +14953,7 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" @@ -14999,6 +15058,21 @@ "dev": true, "license": "MIT" }, + "node_modules/nestjs-pino": { + "version": "4.6.1", + "resolved": "https://registry.npmmirror.com/nestjs-pino/-/nestjs-pino-4.6.1.tgz", + "integrity": "sha512-nuARXa0xpdJ1lY2+fgycIQr6H3g0VgqAWNK3xMYjOFcj2DoPETNXj0lV3Y86nRuI7BUfQp5PGiVoZvT4dTWbpQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "pino": "^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/node-abi": { "version": "3.93.0", "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.93.0.tgz", @@ -15116,6 +15190,15 @@ "node": ">=12.20.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", @@ -15128,6 +15211,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", @@ -15137,16 +15229,22 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-function": "^5.0.0" + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -15176,29 +15274,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmmirror.com/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/oxfmt": { "version": "0.57.0", "resolved": "https://registry.npmmirror.com/oxfmt/-/oxfmt-0.57.0.tgz", @@ -15454,17 +15529,6 @@ "passport-strategy": "^1.0.0" } }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -15623,6 +15687,7 @@ "version": "2.3.2", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -15631,6 +15696,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", @@ -15839,24 +15954,17 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-node": { - "version": "5.39.2", - "resolved": "https://registry.npmmirror.com/posthog-node/-/posthog-node-5.39.2.tgz", - "integrity": "sha512-5piMedjlQ2x+UKLvHWTC5ls5/T1dDZKE1Pu5AKkYh9EkbZOjvu0cac6lWFB7mgbGkKQ0I1bhbjDx1QAYRJ7Unw==", + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, "license": "MIT", - "dependencies": { - "@posthog/core": "^1.39.3" - }, "engines": { - "node": "^20.20.0 || >=22.22.0" + "node": ">=20" }, - "peerDependencies": { - "rxjs": "^7.0.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/prebuild-install": { @@ -15940,6 +16048,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -16026,24 +16150,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, "node_modules/range-parser": { @@ -16163,57 +16273,6 @@ "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", "license": "MIT" }, - "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -16258,9 +16317,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -16292,6 +16351,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -16366,38 +16434,12 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restructure": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/restructure/-/restructure-3.0.2.tgz", "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-6.1.3.tgz", @@ -16458,6 +16500,160 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", + "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^11.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^18.0.0" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -16486,27 +16682,17 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/rw": { @@ -16544,6 +16730,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -16651,12 +16846,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16880,12 +17069,6 @@ "node": ">=18" } }, - "node_modules/size-sensor": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", @@ -16896,6 +17079,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.4.tgz", @@ -16947,6 +17139,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -17030,18 +17231,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", @@ -17103,17 +17292,17 @@ } }, "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17619,6 +17808,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -17748,18 +17955,6 @@ "node": ">= 0.4" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", @@ -17907,61 +18102,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-loader": { - "version": "9.6.2", - "resolved": "https://registry.npmmirror.com/ts-loader/-/ts-loader-9.6.2.tgz", - "integrity": "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "picomatch": "^4.0.0", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "loader-utils": "*", - "typescript": "*", - "webpack": "^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "loader-utils": { - "optional": true - } - } - }, - "node_modules/ts-loader/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ts-loader/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz", @@ -18300,9 +18440,9 @@ } }, "node_modules/typeorm/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -18402,19 +18542,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typeorm/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", @@ -18681,6 +18808,31 @@ "punycode": "^2.1.0" } }, + "node_modules/use-immer": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/use-immer/-/use-immer-0.11.0.tgz", + "integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==", + "license": "MIT", + "peerDependencies": { + "immer": ">=8.0.0", + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -18697,12 +18849,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.1", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -19229,6 +19385,7 @@ "version": "6.2.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -19302,6 +19459,7 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -19311,12 +19469,14 @@ "version": "8.0.0", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19331,6 +19491,7 @@ "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -19381,6 +19542,23 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -19416,7 +19594,10 @@ "version": "2.9.0", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -19522,6 +19703,7 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 0261632..1ec0d36 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,21 @@ "rimraf": "^6.1.3", "turbo": "^2.0.0" }, - "dependencies": { - "@fission-ai/openspec": "^1.5.0" + "overrides": { + "exceljs": { + "uuid": "^11.1.1" + }, + "minimatch@3.1.5": { + "brace-expansion": "^1.1.18" + }, + "minimatch@5.1.9": { + "brace-expansion": "^2.1.4" + }, + "minimatch@9.0.9": { + "brace-expansion": "^2.1.4" + }, + "minimatch@10.2.5": { + "brace-expansion": "^5.0.9" + } } } diff --git a/serve-proxy.js b/serve-proxy.js index ffb64a9..c9baf86 100644 --- a/serve-proxy.js +++ b/serve-proxy.js @@ -63,5 +63,5 @@ const server = http.createServer((req, res) => { }); server.listen(PORT, () => { - console.log(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}`); + process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}\n`); }); From e21f0de42799f627035238306a48d02d06e59782 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:18 +0800 Subject: [PATCH 02/19] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 技术文档.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/技术文档.md b/技术文档.md index 13c3d69..f2ea5f0 100644 --- a/技术文档.md +++ b/技术文档.md @@ -38,7 +38,7 @@ - Vite 8(构建打包) - Ant Design 6(UI 组件库) - ECharts(图表可视化) -- react-router-dom v7(路由) +- react-router v8(路由) - axios(HTTP 客户端) - dayjs(日期处理) - Apple 设计语言:主色 #007AFF,背景 #f5f5f7 From d53bbd8176a75c8292bca7e2ff4748554b7a6d1a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:30 +0800 Subject: [PATCH 03/19] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E9=AA=A8=E6=9E=B6=E8=BF=81=E7=A7=BB=E8=87=B3=20zustan?= =?UTF-8?q?d=20=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=B7=AF=E7=94=B1=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/App.tsx | 4 +- apps/admin/src/api/schemas/ai.ts | 29 ++ apps/admin/src/api/schemas/attendance.ts | 80 +++++ apps/admin/src/api/schemas/core.ts | 330 ++++++++++++++++++ apps/admin/src/api/schemas/dashboard.ts | 79 +++++ apps/admin/src/api/schemas/import-run.ts | 58 +++ apps/admin/src/api/schemas/index.ts | 6 + apps/admin/src/api/schemas/integration.ts | 23 ++ apps/admin/src/auth/menu-policy.ts | 189 +++++----- apps/admin/src/components/BrandLogo.tsx | 24 ++ apps/admin/src/components/DefaultRoute.tsx | 4 +- .../admin/src/components/NotificationBell.tsx | 28 +- .../admin/src/components/PermissionButton.tsx | 3 +- apps/admin/src/components/PermissionRoute.tsx | 2 +- apps/admin/src/components/RouteDock/index.tsx | 15 +- .../RouteKeeper.integration.test.tsx | 2 +- apps/admin/src/components/RouteKeeper.tsx | 2 +- apps/admin/src/hooks/useApiMutation.ts | 39 +++ apps/admin/src/hooks/useViewSensitive.ts | 9 +- apps/admin/src/layouts/MainLayout.tsx | 22 +- apps/admin/src/main.tsx | 18 +- apps/admin/src/pages/Login/index.tsx | 9 +- apps/admin/src/store/app/appStore.ts | 5 +- apps/admin/src/store/middleware/persist.ts | 252 +++++++------ .../src/store/permission/permissionStore.ts | 6 +- .../admin/src/store/settings/settingsStore.ts | 3 +- apps/admin/src/store/types.ts | 3 - apps/admin/src/store/user/userStore.ts | 7 +- apps/admin/src/test/fixtures.ts | 268 -------------- apps/admin/src/test/helpers.ts | 173 --------- apps/admin/src/utils/download.ts | 10 +- apps/admin/src/utils/error.ts | 24 ++ apps/admin/src/utils/validate.ts | 16 + 33 files changed, 1055 insertions(+), 687 deletions(-) create mode 100644 apps/admin/src/api/schemas/ai.ts create mode 100644 apps/admin/src/api/schemas/attendance.ts create mode 100644 apps/admin/src/api/schemas/core.ts create mode 100644 apps/admin/src/api/schemas/dashboard.ts create mode 100644 apps/admin/src/api/schemas/import-run.ts create mode 100644 apps/admin/src/api/schemas/index.ts create mode 100644 apps/admin/src/api/schemas/integration.ts create mode 100644 apps/admin/src/components/BrandLogo.tsx create mode 100644 apps/admin/src/hooks/useApiMutation.ts delete mode 100644 apps/admin/src/test/fixtures.ts delete mode 100644 apps/admin/src/test/helpers.ts create mode 100644 apps/admin/src/utils/error.ts create mode 100644 apps/admin/src/utils/validate.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 2176015..f17a264 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,7 +1,7 @@ import React, { Suspense, lazy } from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { ConfigProvider, App as AntdApp, Spin } from 'antd'; -import { XProvider } from '@ant-design/x'; +import XProvider from '@ant-design/x/es/x-provider'; import xZhCN from '@ant-design/x/es/locale/zh_CN'; import zhCN from 'antd/es/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; diff --git a/apps/admin/src/api/schemas/ai.ts b/apps/admin/src/api/schemas/ai.ts new file mode 100644 index 0000000..4a81d4c --- /dev/null +++ b/apps/admin/src/api/schemas/ai.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const aiConfigSchema = z + .object({ + id: z.number(), + provider: z.string(), + baseUrl: z.string(), + hasApiKey: z.boolean(), + hasDatabaseKey: z.boolean(), + maskedApiKey: z.string().nullable(), + keySource: z.enum(['database', 'environment', 'none']), + defaultModel: z.string().nullable(), + enabled: z.boolean(), + supportsVision: z.boolean(), + timeoutMs: z.number(), + reasoningEffort: z.string().nullable(), + verified: z.boolean(), + lastTestedAt: z.string().nullable(), + lastTestLatencyMs: z.number().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); + +export const aiConfigEnvelopeSchema = z + .object({ success: z.boolean(), data: aiConfigSchema }) + .passthrough(); + +/** 导入任务 */ diff --git a/apps/admin/src/api/schemas/attendance.ts b/apps/admin/src/api/schemas/attendance.ts new file mode 100644 index 0000000..66f4231 --- /dev/null +++ b/apps/admin/src/api/schemas/attendance.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +export const attendanceRecordSchema = z + .object({ + id: z.number(), + studentId: z.number(), + classId: z.number().nullable(), + attendanceDate: z.string(), + session: z.string(), + status: z.string(), + remark: z.string().nullable(), + createdAt: z.string(), + student: z + .object({ id: z.number(), name: z.string(), studentNo: z.string().nullable().optional() }) + .passthrough(), + class: z.object({ id: z.number(), name: z.string() }).passthrough().nullable(), + }) + .passthrough(); + +export const attendanceRecordsResponseSchema = z + .object({ list: z.array(attendanceRecordSchema), total: z.number() }) + .passthrough(); + +export const attendanceSummarySchema = z + .object({ + total: z.number(), + present: z.number(), + late: z.number(), + absent: z.number(), + leave: z.number(), + pending: z.number(), + }) + .passthrough(); + +export const dingTalkSyncStatusSchema = z + .object({ + lastPulledAt: z.string().nullable(), + action: z.string().nullable(), + username: z.string().nullable(), + detail: z.string().nullable(), + }) + .passthrough(); + +/** 学生档案聚合 */ + +export const attendanceClassOptionSchema = z + .object({ classId: z.number(), className: z.string() }) + .passthrough(); + +export const attendanceClassOptionsSchema = z.array(attendanceClassOptionSchema); + +export const attendanceAlertSchema = z + .object({ id: z.number(), type: z.string(), message: z.string() }) + .passthrough(); + +export const attendanceAlertsSchema = z.array(attendanceAlertSchema); + +export const attendancePeriodSchema = z + .object({ + periodKey: z.string(), + label: z.string(), + startTime: z.string(), + endTime: z.string(), + sortOrder: z.number(), + enabled: z.boolean(), + }) + .passthrough(); + +export const attendancePeriodsSchema = z.array(attendancePeriodSchema); + +export const attendanceScheduleOptionSchema = z + .object({ + id: z.number(), + subject: z.string(), + startTime: z.string(), + endTime: z.string(), + }) + .passthrough(); + +export const attendanceScheduleOptionsSchema = z.array(attendanceScheduleOptionSchema); diff --git a/apps/admin/src/api/schemas/core.ts b/apps/admin/src/api/schemas/core.ts new file mode 100644 index 0000000..38295e9 --- /dev/null +++ b/apps/admin/src/api/schemas/core.ts @@ -0,0 +1,330 @@ +import { z } from 'zod'; + +export const studentProfileAggregateSchema = z + .object({ + student: z + .object({ + id: z.number(), + name: z.string(), + phone: z.string(), + idNumber: z.string(), + studentNo: z.string(), + status: z.string(), + }) + .passthrough(), + profile: z.record(z.string(), z.unknown()).nullable(), + enrollments: z.array(z.record(z.string(), z.unknown())), + examScores: z.array(z.record(z.string(), z.unknown())), + learningRecords: z.array(z.record(z.string(), z.unknown())), + result: z.record(z.string(), z.unknown()).nullable(), + attachments: z.array(z.record(z.string(), z.unknown())), + attendances: z.array(z.record(z.string(), z.unknown())), + }) + .passthrough(); + +/** 权限树 */ +export const permissionItemSchema = z + .object({ id: z.number(), code: z.string(), name: z.string(), group: z.string() }) + .passthrough(); + +export const permissionTreeSchema = z.array( + z.object({ group: z.string(), permissions: z.array(permissionItemSchema) }).passthrough(), +); + +/** 机构 */ +export const organizationSchema = z + .object({ id: z.number(), name: z.string(), code: z.string(), status: z.string() }) + .passthrough(); + +export const organizationsSchema = z.array(organizationSchema); + +export const organizationOptionSchema = z + .object({ id: z.number(), name: z.string() }) + .passthrough(); + +export const organizationOptionsSchema = z.array(organizationOptionSchema); + +/** 账单 */ +export const billSchema = z + .object({ + id: z.number(), + status: z.string(), + student: z + .object({ id: z.number(), name: z.string() }) + .passthrough() + .nullable() + .optional(), + }) + .passthrough(); + +export const billsSchema = z.array(billSchema); + +/** 班级 */ +export const classSchema = z + .object({ + id: z.number(), + name: z.string(), + code: z.string(), + classType: z.string(), + isArchived: z.boolean(), + }) + .passthrough(); + +export const classesSchema = z.array(classSchema); + +/** 教师 */ +export const teacherSchema = z + .object({ id: z.number(), username: z.string(), name: z.string() }) + .passthrough(); + +export const teacherListSchema = z + .object({ list: z.array(teacherSchema), total: z.number() }) + .passthrough(); + +/** 角色 / 用户 */ +export const roleSchema = z + .object({ id: z.number(), name: z.string(), status: z.number() }) + .passthrough(); + +export const rolesSchema = z.array(roleSchema); + +export const userSchema = z + .object({ id: z.number(), username: z.string(), name: z.string() }) + .passthrough(); + +export const usersSchema = z.array(userSchema); + +/** 操作日志 */ +export const operationLogSchema = z + .object({ + id: z.number(), + module: z.string(), + action: z.string(), + username: z.string(), + createdAt: z.string(), + }) + .passthrough(); + +export const operationLogsSchema = z + .object({ data: z.array(operationLogSchema), total: z.number() }) + .passthrough(); + +/** 通知 */ +export const notificationSchema = z + .object({ + id: z.number(), + type: z.string(), + title: z.string(), + content: z.string(), + isRead: z.boolean(), + createdAt: z.string(), + }) + .passthrough(); + +export const notificationsSchema = z.array(notificationSchema); + +/** 考勤机 / 教室选项 */ +export const attendanceDeviceSchema = z + .object({ id: z.number(), deviceSn: z.string(), deviceName: z.string(), status: z.string() }) + .passthrough(); + +export const attendanceDevicesSchema = z.array(attendanceDeviceSchema); + +export const classroomOptionSchema = z + .object({ id: z.number(), name: z.string() }) + .passthrough(); + +export const classroomOptionsSchema = z.array(classroomOptionSchema); + +/** 宿舍 / 教室 */ +export const roomSchema = z + .object({ + id: z.number(), + roomNumber: z.string(), + status: z.string(), + currentCount: z.number(), + capacity: z.number(), + }) + .passthrough(); + +export const roomsOverviewSchema = z.array(roomSchema); + +export const classroomSchema = z + .object({ + id: z.number(), + name: z.string(), + building: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); + +export const classroomsSchema = z.array(classroomSchema); + +/** 学生 */ +export const studentSchema = z + .object({ + id: z.number(), + name: z.string(), + studentNo: z.string().optional(), + status: z.string(), + }) + .passthrough(); + +export const studentsSchema = z.array(studentSchema); + +/** 押金 */ +export const depositSchema = z + .object({ id: z.number(), studentId: z.number(), amount: z.number(), status: z.string() }) + .passthrough(); + +export const depositsSchema = z.array(depositSchema); + +export const depositStudentLookupSchema = z + .object({ studentId: z.number(), name: z.string().optional() }) + .passthrough(); + +export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema); + +export const eligibleStudentSchema = z + .object({ studentId: z.number(), roomId: z.number(), roomNumber: z.string() }) + .passthrough(); + +export const eligibleStudentsSchema = z.array(eligibleStudentSchema); + +/** 钱包 */ +export const walletSchema = z + .object({ + studentId: z.number(), + studentName: z.string(), + balance: z.number(), + outstandingAmount: z.number(), + }) + .passthrough(); + +export const walletsSchema = z.array(walletSchema); + +export const roomTypesSchema = z.array(z.string()); + +/** 费用 */ +export const expenseRecordSchema = z + .object({ + id: z.number(), + expenseType: z.string(), + amount: z.number(), + status: z.string().optional(), + }) + .passthrough(); + +export const expenseRecordsSchema = z.array(expenseRecordSchema); + +export const expenseLookupsSchema = z + .object({ + rooms: z.array(z.record(z.string(), z.unknown())), + students: z.array(z.record(z.string(), z.unknown())), + }) + .passthrough(); + +export const expenseTypesSchema = z.array( + z.object({ code: z.string(), name: z.string(), category: z.string() }), +); + +/** 入住 */ +export const occupancySchema = z + .object({ + id: z.number(), + studentId: z.number(), + roomId: z.number(), + checkInDate: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); + +export const occupanciesSchema = z.array(occupancySchema); + +/** 排课 */ +export const scheduleLookupsSchema = z + .object({ + classrooms: z.array(classroomOptionSchema), + classes: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()), + }) + .passthrough(); + +export const weeklyScheduleSchema = z.record( + z.string(), + z.record( + z.string(), + z.array( + z + .object({ + id: z.number().nullable(), + classId: z.number().nullable(), + classroomId: z.number(), + weekDay: z.number(), + startTime: z.string(), + endTime: z.string(), + }) + .passthrough(), + ), + ), +); + +/** 租赁订单 */ +export const rentalSchema = z + .object({ + id: z.number(), + classroom: z + .object({ id: z.number(), name: z.string() }) + .passthrough() + .nullable() + .optional(), + lesseeOrganization: z + .object({ id: z.number(), name: z.string() }) + .passthrough() + .nullable() + .optional(), + status: z.string().optional(), + }) + .passthrough(); + +export const rentalsSchema = z.array(rentalSchema); + +/** 考试 */ +export const examSchema = z + .object({ + id: z.number(), + examName: z.string(), + examType: z.string(), + isArchived: z.boolean(), + }) + .passthrough(); + +export const examsSchema = z.array(examSchema); + +export const examDetailSchema = z + .object({ id: z.number(), examName: z.string() }) + .passthrough(); + +export const classOptionSchema = z + .object({ id: z.number(), name: z.string() }) + .passthrough(); + +export const classOptionsSchema = z.array(classOptionSchema); + +export const studentFilterLookupsSchema = z + .object({ + classes: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()), + teachers: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()), + }) + .passthrough(); + +/** 教师工作台 */ +export const teacherWorkspaceSchema = z + .object({ + assignedClasses: z.array( + z.object({ classId: z.number(), className: z.string() }).passthrough(), + ), + todaySchedules: z.array(z.record(z.string(), z.unknown())), + }) + .passthrough(); + +/** 集成配置 */ diff --git a/apps/admin/src/api/schemas/dashboard.ts b/apps/admin/src/api/schemas/dashboard.ts new file mode 100644 index 0000000..99a3ea0 --- /dev/null +++ b/apps/admin/src/api/schemas/dashboard.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +export const classroomScheduleSchema = z + .object({ + classrooms: z.array(z.record(z.string(), z.unknown())), + organizations: z.array(z.record(z.string(), z.unknown())), + matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))), + summary: z.record(z.string(), z.record(z.string(), z.unknown())), + days: z.number().optional(), + }) + .passthrough(); + +/** Dashboard 统计 */ +export const dashboardStatsSchema = z + .object({ + totalRooms: z.number(), + totalStudents: z.number(), + occupiedBeds: z.number(), + totalCapacity: z.number(), + occupancyRate: z.string(), + classroomCount: z.number(), + classroomOccupancyRate: z.string(), + todayAttendanceRate: z.string().optional(), + monthlyIncome: z.number(), + classCount: z.number(), + teacherCount: z.number(), + pendingDeposits: z.number(), + activeRentals: z.number(), + todayPresent: z.number(), + occupancyByBuilding: z.array( + z.object({ building: z.string(), count: z.string() }).passthrough(), + ), + attendanceByStatus: z.record(z.string(), z.number()), + expenseByType: z.array(z.object({ type: z.string(), total: z.string() }).passthrough()), + attendanceTrend: z.array(z.object({ date: z.string(), rate: z.string() }).passthrough()), + incomeTrend: z.array(z.object({ month: z.string(), amount: z.number() }).passthrough()), + }) + .passthrough(); + +export const roomRankingSchema = z.array( + z.object({ roomNumber: z.string(), total: z.string() }).passthrough(), +); + +export const classAttendanceRankingSchema = z + .object({ + top: z.array( + z + .object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() }) + .passthrough(), + ), + bottom: z.array( + z + .object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() }) + .passthrough(), + ), + }) + .passthrough(); + +export const ganttRoomsSchema = z.array( + z + .object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) }) + .passthrough(), +); + +export const classroomOccupanciesSchema = z.array( + z + .object({ name: z.string(), building: z.string(), capacity: z.number(), occupancy: z.number() }) + .passthrough(), +); + +export const classroomUtilStatsSchema = z + .object({ + totalClassrooms: z.number(), + inUseCount: z.number(), + utilizationRate: z.string(), + }) + .passthrough(); + +/** 考勤元数据 */ diff --git a/apps/admin/src/api/schemas/import-run.ts b/apps/admin/src/api/schemas/import-run.ts new file mode 100644 index 0000000..4ada227 --- /dev/null +++ b/apps/admin/src/api/schemas/import-run.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +export const importSheetMetaSchema = z + .object({ + name: z.string(), + headers: z.array(z.string()), + rowCount: z.number(), + suggestedStepKey: z.string().nullable(), + }) + .passthrough(); + +export const importStepSummarySchema = z + .object({ + total: z.number(), + valid: z.number(), + error: z.number(), + create: z.number(), + update: z.number(), + skip: z.number(), + }) + .passthrough() + .nullable(); + +export const importStepDetailSchema = z + .object({ + id: z.number(), + stepKey: z.string(), + label: z.string(), + sheets: z.array(z.string()), + status: z.string(), + mapping: z.record(z.string(), z.string()), + summary: importStepSummarySchema, + committedAt: z.string().nullable(), + }) + .passthrough(); + +export const importRunDetailSchema = z + .object({ + id: z.string(), + fileName: z.string(), + source: z.enum(['ai', 'manual']), + status: z.string(), + currentStepKey: z.string().nullable(), + createdAt: z.string(), + sheets: z.array(importSheetMetaSchema), + steps: z.array(importStepDetailSchema), + }) + .passthrough(); + +export const importRunEnvelopeSchema = z + .object({ + success: z.boolean(), + data: importRunDetailSchema, + message: z.string().optional(), + }) + .passthrough(); + +/** 考勤记录 */ diff --git a/apps/admin/src/api/schemas/index.ts b/apps/admin/src/api/schemas/index.ts new file mode 100644 index 0000000..a3a7173 --- /dev/null +++ b/apps/admin/src/api/schemas/index.ts @@ -0,0 +1,6 @@ +export * from './core'; +export * from './attendance'; +export * from './dashboard'; +export * from './import-run'; +export * from './ai'; +export * from './integration'; diff --git a/apps/admin/src/api/schemas/integration.ts b/apps/admin/src/api/schemas/integration.ts new file mode 100644 index 0000000..d6639a3 --- /dev/null +++ b/apps/admin/src/api/schemas/integration.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const integrationConfigSchema = z + .object({ + success: z.boolean(), + data: z.array( + z + .object({ + type: z.string(), + verify: z.boolean(), + config: z.record(z.string(), z.unknown()), + }) + .passthrough(), + ), + }) + .passthrough(); + +/** 金数据规则 */ +export const jinshujuRulesSchema = z.array( + z.object({ id: z.number(), name: z.string(), formToken: z.string() }).passthrough(), +); + +/** 教室排课总览 */ diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index ceff6c5..7f427c4 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 export interface AppMenuItem { key: string; label: string; @@ -36,100 +37,112 @@ const ROLE_ALIASES: Record = { super_admin: 'super', }; +function entry(key: string, label: string, icon: string, permission: string): MenuEntry { + return { key, label, icon, permission }; +} + +function section( + key: string, + label: string, + icon: string, + roles: string[], + children: MenuEntry[], +): MenuSection { + return { key, label, icon, roles, children }; +} + const SECTIONS: MenuSection[] = [ - { - key: 'teaching-group', - label: '教学工作', - icon: 'calendar', - roles: ['teacher'], - children: [ - { - key: '/teacher-workspace', - label: '今日教学', - icon: 'workspace', - permission: 'teacher-workspace:view', - }, - { key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' }, - { key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' }, + section( + 'teaching-group', + '教学工作', + 'calendar', + ['teacher'], + [ + entry('/teacher-workspace', '今日教学', 'workspace', 'teacher-workspace:view'), + + entry('/schedules', '我的排课', 'calendar', 'schedule:view'), + + entry('/attendance', '课程考勤', 'attendance', 'attendance:view'), ], - }, - { - key: 'academic-group', - label: '教务管理', - icon: 'academic', - roles: ['academic', 'super'], - children: [ - { key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' }, - { key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' }, - { key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' }, - { key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' }, - { key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' }, - { key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' }, - { key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' }, + ), + section( + 'academic-group', + '教务管理', + 'academic', + ['academic', 'super'], + [ + entry('/students', '学生管理', 'students', 'student:view'), + + entry('/classes', '班级管理', 'classes', 'class:view'), + + entry('/exams', '考试管理', 'exam', 'exam:view'), + + entry('/teachers', '教师管理', 'teachers', 'teacher:view'), + + entry('/schedules', '排课管理', 'calendar', 'schedule:view'), + + entry('/attendance', '历史考勤', 'attendance', 'attendance:view'), + + entry('/classrooms', '教室查看', 'classroom', 'classroom:view'), ], - }, - { - key: 'accommodation-group', - label: '住宿运营', - icon: 'home', - roles: ['accommodation', 'super'], - children: [ - { key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' }, - { key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' }, - { key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' }, - { key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' }, - { key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' }, - { key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' }, - { key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' }, + ), + section( + 'accommodation-group', + '住宿运营', + 'home', + ['accommodation', 'super'], + [ + entry('/room-visual', '住宿总览', 'overview', 'room:view'), + + entry('/rooms', '房间管理', 'home', 'room:view'), + + entry('/occupancies', '入住管理', 'occupancy', 'occupancy:view'), + + entry('/expenses', '费用管理', 'expense', 'expense:view'), + + entry('/bills', '账单管理', 'bill', 'bill:view'), + + entry('/wallets', '学生余额', 'wallet', 'wallet:view'), + + entry('/deposits', '押金管理', 'deposit', 'deposit:view'), ], - }, - { - key: 'classroom-group', - label: '教室运营', - icon: 'classroom', - roles: ['classroom', 'super'], - children: [ - { - key: '/classroom-schedule', - label: '教室排期', - icon: 'calendar', - permission: 'rental:view', - }, - { key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' }, - { - key: '/attendance-devices', - label: '考勤机绑定', - icon: 'attendance', - permission: 'classroom:view', - }, - { key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' }, - { - key: '/organizations', - label: '机构管理', - icon: 'organization', - permission: 'organization:view', - }, + ), + section( + 'classroom-group', + '教室运营', + 'classroom', + ['classroom', 'super'], + [ + entry('/classroom-schedule', '教室排期', 'calendar', 'rental:view'), + + entry('/classrooms', '教室管理', 'classroom', 'classroom:view'), + + entry('/attendance-devices', '考勤机绑定', 'attendance', 'classroom:view'), + + entry('/classroom-rentals', '租赁订单', 'rental', 'rental:view'), + + entry('/organizations', '机构管理', 'organization', 'organization:view'), ], - }, - { - key: 'system-group', - label: '系统管理', - icon: 'settings', - roles: ['system', 'super'], - children: [ - { key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' }, - { key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' }, - { key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' }, - { key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' }, - { - key: '/integration-config', - label: '钉钉集成', - icon: 'integration', - permission: 'integration:read', - }, - { key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' }, + ), + section( + 'system-group', + '系统管理', + 'settings', + ['system', 'super'], + [ + entry('/users', '账号管理', 'users', 'user:view'), + + entry('/roles', '角色管理', 'role', 'role:view'), + + entry('/permissions', '权限一览', 'permission', 'role:view'), + + entry('/operation-logs', '操作日志', 'log', 'log:view'), + + entry('/integration-config', '钉钉集成', 'integration', 'integration:read'), + + entry('/ai-config', 'AI 配置', 'ai', 'ai:config:read'), ], - }, + ), ]; export function getRoleDomains( diff --git a/apps/admin/src/components/BrandLogo.tsx b/apps/admin/src/components/BrandLogo.tsx new file mode 100644 index 0000000..e0988b3 --- /dev/null +++ b/apps/admin/src/components/BrandLogo.tsx @@ -0,0 +1,24 @@ +import { ReadOutlined } from '@ant-design/icons'; + +const BRAND_COLOR = '#7e14ff'; + +/** 全局品牌标识:登录页 / 侧边栏 / 页头统一使用 */ +export function BrandLogo({ size = 32 }: { size?: number }) { + return ( + + + + ); +} diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index c9566b5..af76a1b 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate } from 'react-router'; import { Result, Spin } from 'antd'; import { usePermission } from '../hooks/usePermission'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; @@ -7,10 +7,10 @@ import { useUserStore } from '../store/user/userStore'; const DefaultRoute: React.FC = () => { const { permissions, permissionsReady } = usePermission(); + const roles = useUserStore((state) => state.user?.roles ?? []); if (!permissionsReady) { return ; } - const roles = useUserStore((state) => state.user?.roles ?? []); const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; return ( diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index e8fe643..3f194bc 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -1,7 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { Badge, Popover, Button, List, Typography, Empty } from 'antd'; import { BellOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; +import dayjs from 'dayjs'; +import { useInterval } from 'usehooks-ts'; import api from '../api'; import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; import { useUserStore } from '../store/user/userStore'; @@ -17,25 +19,19 @@ interface NotificationItem { } function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - return `${days}天前`; + return dayjs(dateStr).fromNow(); } const NotificationBell: React.FC = () => { const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [open, setOpen] = useState(false); + const [sseDown, setSseDown] = useState(false); const navigate = useNavigate(); const fetchNotifications = async () => { try { - const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[]; + const data = await api.get('/notifications?limit=20'); setNotifications(data); } catch { /* ignore */ @@ -44,7 +40,7 @@ const NotificationBell: React.FC = () => { const fetchUnread = async () => { try { - const data = (await api.get('/notifications/unread-count')) as unknown as { count: number }; + const data = await api.get<{ count: number }>('/notifications/unread-count'); setUnreadCount(data.count); } catch { /* ignore */ @@ -52,7 +48,9 @@ const NotificationBell: React.FC = () => { }; const openRef = useRef(open); openRef.current = open; - const retryRef = useRef(null); + useInterval(() => { + void fetchUnread(); + }, sseDown ? 60_000 : null); // SSE connection — decoupled from popover open state useEffect(() => { @@ -71,13 +69,11 @@ const NotificationBell: React.FC = () => { }; es.onerror = () => { es.close(); - if (retryRef.current !== null) clearInterval(retryRef.current); - retryRef.current = window.setInterval(fetchUnread, 60_000); + setSseDown(true); }; return () => { es.close(); - clearInterval(retryRef.current ?? undefined); - retryRef.current = null; + setSseDown(false); }; }, []); diff --git a/apps/admin/src/components/PermissionButton.tsx b/apps/admin/src/components/PermissionButton.tsx index 26c2c0a..c2127fa 100644 --- a/apps/admin/src/components/PermissionButton.tsx +++ b/apps/admin/src/components/PermissionButton.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Button } from 'antd'; -import type { ButtonProps } from 'antd'; +import { Button, type ButtonProps } from 'antd'; import { usePermission } from '../hooks/usePermission'; interface PermissionButtonProps extends ButtonProps { diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index ff3f805..0f37570 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Result, Button, Spin } from 'antd'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; import { usePermission } from '../hooks/usePermission'; import { useUserStore } from '../store/user/userStore'; diff --git a/apps/admin/src/components/RouteDock/index.tsx b/apps/admin/src/components/RouteDock/index.tsx index 79359ab..14bcacc 100644 --- a/apps/admin/src/components/RouteDock/index.tsx +++ b/apps/admin/src/components/RouteDock/index.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useMemo } from 'react'; -import type { DragEndEvent } from '@dnd-kit/core'; -import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; import { arrayMove, horizontalListSortingStrategy, @@ -8,9 +14,8 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Tabs } from 'antd'; -import type { TabsProps } from 'antd'; -import type { Location } from 'react-router-dom'; +import { Tabs, type TabsProps } from 'antd'; +import type { Location } from 'react-router'; import type { AppMenuItem } from '../../auth/menu-policy'; import { useAppStore } from '../../store'; diff --git a/apps/admin/src/components/RouteKeeper.integration.test.tsx b/apps/admin/src/components/RouteKeeper.integration.test.tsx index 8c23e72..1aa0c4d 100644 --- a/apps/admin/src/components/RouteKeeper.integration.test.tsx +++ b/apps/admin/src/components/RouteKeeper.integration.test.tsx @@ -1,6 +1,6 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router'; import { afterEach, describe, expect, it } from 'vitest'; import { RouteKeeper } from './RouteKeeper'; diff --git a/apps/admin/src/components/RouteKeeper.tsx b/apps/admin/src/components/RouteKeeper.tsx index 4ae4111..a07a8b7 100644 --- a/apps/admin/src/components/RouteKeeper.tsx +++ b/apps/admin/src/components/RouteKeeper.tsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react'; -import { useLocation, useOutlet } from 'react-router-dom'; +import { useLocation, useOutlet } from 'react-router'; const MAX_CACHED_PAGES = 30; diff --git a/apps/admin/src/hooks/useApiMutation.ts b/apps/admin/src/hooks/useApiMutation.ts new file mode 100644 index 0000000..3aefd86 --- /dev/null +++ b/apps/admin/src/hooks/useApiMutation.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query'; +import { message } from '../ui/app-message'; +import { getErrorMessage } from '../utils/error'; + +interface UseApiMutationOptions { + /** 成功后自动失效的查询 key(触发列表/详情刷新) */ + invalidate?: QueryKey[]; + /** 成功后回调(例如关闭弹窗) */ + onSuccess?: (data: TData, vars: TVars) => void; + /** 失败回调;默认统一用 getErrorMessage 弹错误提示 */ + onError?: (error: unknown) => void; +} + +/** + * useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries, + * 消除手写 `await api.xxx(); await fetchData();` 样板。 + */ +export function useApiMutation( + mutationFn: (vars: TVars) => Promise, + options: UseApiMutationOptions = {}, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: (data, vars) => { + for (const key of options.invalidate ?? []) { + void queryClient.invalidateQueries({ queryKey: key }); + } + options.onSuccess?.(data, vars); + }, + onError: (error) => { + if (options.onError) { + options.onError(error); + } else { + message.error(getErrorMessage(error)); + } + }, + }); +} diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 6a99021..856f9e2 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import { Modal } from 'antd'; +import { App } from 'antd'; import api from '../api'; import { message } from '../ui/app-message'; @@ -13,8 +13,9 @@ import { message } from '../ui/app-message'; * already-open confirm modal is destroyed. */ export function useViewSensitive(studentId: number, module: string, canLog: boolean) { + const { modal } = App.useApp(); const canLogRef = useRef(canLog); - const modalRef = useRef | null>(null); + const modalRef = useRef | null>(null); canLogRef.current = canLog; useEffect(() => { @@ -31,7 +32,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool return useCallback( (field: string, value: string) => { if (!canLogRef.current) return; - modalRef.current = Modal.confirm({ + modalRef.current = modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', @@ -50,7 +51,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool message.error('操作日志记录失败,请稍后重试'); return; } - Modal.info({ + modal.info({ title: field, content: value, okText: '关闭', diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 64ffeed..1d27a1d 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router'; import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd'; +import { BrandLogo } from '../components/BrandLogo'; import { DashboardOutlined, TeamOutlined, @@ -262,7 +263,17 @@ const MainLayout: React.FC = () => { borderBottom: '1px solid #e5e5e7', }} > - {collapsed ? '学' : '学生管理系统'} +
+ + {!collapsed && 学生管理系统} +
{menuContent} @@ -275,7 +286,12 @@ const MainLayout: React.FC = () => { size={240} styles={{ body: { padding: 0 } }} className="app-navigation-drawer" - title="学生管理系统" + title={ + + + 学生管理系统 + + } > {menuContent} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 55bbb28..7d05a2d 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -1,11 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from './App'; import './index.css'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import advancedFormat from 'dayjs/plugin/advancedFormat'; +import relativeTime from 'dayjs/plugin/relativeTime'; import weekday from 'dayjs/plugin/weekday'; import localeData from 'dayjs/plugin/localeData'; import weekOfYear from 'dayjs/plugin/weekOfYear'; @@ -15,6 +18,7 @@ import updateLocale from 'dayjs/plugin/updateLocale'; // 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效 dayjs.extend(customParseFormat); dayjs.extend(advancedFormat); +dayjs.extend(relativeTime); dayjs.extend(weekday); dayjs.extend(localeData); dayjs.extend(weekOfYear); @@ -24,8 +28,20 @@ dayjs.extend(updateLocale); // 必须在所有插件加载后设置 locale dayjs.locale('zh-cn'); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 30_000, + }, + }, +}); + ReactDOM.createRoot(document.getElementById('root')!).render( - + + + {import.meta.env.DEV && } + , ); diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index cd9f195..e97b375 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { Form, Input, Button, Card, Typography } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import api from '../../api'; +import { BrandLogo } from '../../components/BrandLogo'; import { message } from '../../ui/app-message'; import { findRoleAwareLandingPath } from '../../auth/menu-policy'; import { usePermissionStore } from '../../store/permission/permissionStore'; @@ -59,7 +60,11 @@ const LoginPage: React.FC = () => { }} >
- + <BrandLogo size={48} /> + <Title + level={3} + style={{ margin: '14px 0 0', fontWeight: 600, color: '#1d1d1f' }} + > 学生管理系统

学生综合管理平台

diff --git a/apps/admin/src/store/app/appStore.ts b/apps/admin/src/store/app/appStore.ts index e517d95..aaed467 100644 --- a/apps/admin/src/store/app/appStore.ts +++ b/apps/admin/src/store/app/appStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; -import { appUiPersistStorage, APP_UI_STORAGE_NAME } from '../middleware/persist'; +import { APP_UI_STORAGE_NAME, appUiPersistStorage, migrateAppUiState } from '../middleware/persist'; import type { AppPersistedState, AppStore } from './appTypes'; /** @@ -59,7 +59,8 @@ export const useAppStore = create()( sidebarCollapsed: state.sidebarCollapsed, routeDockTabs: state.routeDockTabs, }), - version: 1, + version: 2, + migrate: migrateAppUiState, }, ), { name: 'app-store', enabled: import.meta.env.DEV }, diff --git a/apps/admin/src/store/middleware/persist.ts b/apps/admin/src/store/middleware/persist.ts index 3bc98eb..3fe7c13 100644 --- a/apps/admin/src/store/middleware/persist.ts +++ b/apps/admin/src/store/middleware/persist.ts @@ -1,9 +1,11 @@ /** * 持久化中间件基础设施。 * - * 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key - * (token / user / permissions / gongxue-route-dock)包装成 zustand - * persist 的 StateStorage,保证迁移前后数据格式兼容。 + * 所有 Store 统一使用 zustand 官方 persist + createJSONStorage(localStorage)。 + * zustand 只有在「新 key 下已存在数据」时才会执行 migrate,因此旧 key/旧格式 + * 不能只靠 migrate 迁移:这里通过 StateStorage.getItem 的旧值回退(把旧数据 + * 包装成 version:1 的 persist envelope),保证升级后首次加载就能触发 migrate, + * 并在首次写入新格式时清理旧 key。 */ import { createJSONStorage, type StateStorage } from 'zustand/middleware'; import type { AppPersistedState, DockTab } from '../app/appTypes'; @@ -38,111 +40,94 @@ function isDockTab(value: unknown): value is DockTab { ); } -/** - * 用户会话持久化:继续使用旧的 `token` / `user` 两个 key, - * 保持与后端、既有代码及浏览器缓存格式一致。 - */ -const legacyAuthStorage: StateStorage = { - getItem: () => { - const token = localStorage.getItem(LEGACY_TOKEN_KEY); - const rawUser = localStorage.getItem(LEGACY_USER_KEY); - if (token === null && rawUser === null) return null; - let user: UserInfo | null = null; - if (rawUser !== null) { - try { - const parsed: unknown = JSON.parse(rawUser); - user = isRecord(parsed) ? (parsed as UserInfo) : null; - } catch { - user = null; - } - } - return JSON.stringify({ state: { token, user }, version: 1 }); - }, - setItem: (_name, value) => { +function readLegacyAuth(): { token: string | null; user: UserInfo | null } { + const token = localStorage.getItem(LEGACY_TOKEN_KEY); + const rawUser = localStorage.getItem(LEGACY_USER_KEY); + let user: UserInfo | null = null; + if (rawUser !== null) { try { - const persisted = JSON.parse(value) as { state?: UserPersistedState }; - const { token, user } = persisted.state ?? {}; - if (token) { - localStorage.setItem(LEGACY_TOKEN_KEY, token); - } else { - localStorage.removeItem(LEGACY_TOKEN_KEY); - } - if (user) { - localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(user)); - } else { - localStorage.removeItem(LEGACY_USER_KEY); - } + const parsed: unknown = JSON.parse(rawUser); + user = isRecord(parsed) ? (parsed as UserInfo) : null; } catch { - // 持久化写入失败不应影响应用运行 + user = null; } - }, - removeItem: () => { + } + return { token, user }; +} + +interface LegacyAdapter { + legacyValue: () => string | null; + clearLegacy: () => void; +} + +/** 新 key 无数据时回退到旧 key,首次写入新格式后清理旧 key */ +function legacyFallbackStorage(adapter: LegacyAdapter): StateStorage { + return { + getItem: (name) => { + const current = localStorage.getItem(name); + if (current !== null) return current; + return adapter.legacyValue(); + }, + setItem: (name, value) => { + try { + localStorage.setItem(name, value); + } catch { + // 持久化失败不应影响应用运行 + } + try { + adapter.clearLegacy(); + } catch { + // 清理失败不影响应用运行 + } + }, + removeItem: (name) => { + try { + localStorage.removeItem(name); + } catch { + // 持久化失败不应影响应用运行 + } + try { + adapter.clearLegacy(); + } catch { + // 清理失败不影响应用运行 + } + }, + }; +} + +/** 会话:旧 token/user 两个 key 包装成 version:1 envelope */ +function legacyAuthValue(): string | null { + const { token, user } = readLegacyAuth(); + if (token === null && user === null) return null; + return JSON.stringify({ state: { token, user }, version: 1 }); +} + +const authAdapter: LegacyAdapter = { + legacyValue: legacyAuthValue, + clearLegacy: () => { localStorage.removeItem(LEGACY_TOKEN_KEY); localStorage.removeItem(LEGACY_USER_KEY); }, }; -/** - * 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。 - * 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed, - * 直到 `/auth/profile` 校验成功。 - */ -const legacyPermissionStorage: StateStorage = { - getItem: () => { - const raw = localStorage.getItem(PERMISSION_STORAGE_NAME); +export const authPersistStorage = createJSONStorage(() => + legacyFallbackStorage(authAdapter), +); + +/** 权限:旧格式是 permissions 下的裸数组,读取时统一包装成 envelope 以触发 migrate */ +const permissionStorage: StateStorage = { + getItem: (name) => { + const raw = localStorage.getItem(name); if (!raw) return null; try { const parsed: unknown = JSON.parse(raw); if (Array.isArray(parsed)) { return JSON.stringify({ - state: { permissions: parsed.filter(isString), status: 'unknown' }, + state: { permissions: parsed.filter(isString) }, version: 1, }); } - if (isRecord(parsed) && isRecord(parsed.state)) { - const permissions = Array.isArray(parsed.state.permissions) - ? parsed.state.permissions.filter(isString) - : []; - return JSON.stringify({ - state: { permissions, status: 'unknown' }, - version: 1, - }); - } - } catch { - // 损坏的缓存按无权限处理 - } - return null; - }, - setItem: (_name, value) => { - try { - const persisted = JSON.parse(value) as { state?: PermissionPersistedState }; - const permissions = Array.isArray(persisted.state?.permissions) - ? persisted.state.permissions.filter(isString) - : []; - localStorage.setItem(PERMISSION_STORAGE_NAME, JSON.stringify(permissions)); - } catch { - // 忽略损坏数据 - } - }, - removeItem: () => { - localStorage.removeItem(PERMISSION_STORAGE_NAME); - }, -}; - -/** - * 应用 UI 状态持久化:新 key `gongxue-app-ui`, - * 首次读取时自动迁移旧 key `gongxue-route-dock` 中已打开的页签。 - */ -const appUiStorage: StateStorage = { - getItem: (name) => { - const current = localStorage.getItem(name); - if (current) return current; - const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY); - if (!legacy) return null; - try { - const parsed: unknown = JSON.parse(legacy); - const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : []; - return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 }); + return raw; } catch { return null; } @@ -163,13 +148,84 @@ const appUiStorage: StateStorage = { }, }; -/** 会话 Store 使用的 persist storage(兼容旧 token/user key) */ -export const authPersistStorage = createJSONStorage(() => legacyAuthStorage); +export const permissionPersistStorage = createJSONStorage(() => permissionStorage); -/** 权限 Store 使用的 persist storage(兼容旧 permissions key) */ -export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage); +/** 应用 UI:旧 gongxue-route-dock key 包装成 version:1 envelope */ +function legacyDockValue(): string | null { + const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY); + if (!legacy) return null; + try { + const parsed: unknown = JSON.parse(legacy); + const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : []; + return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 }); + } catch { + return null; + } +} -/** 应用 UI Store 使用的 persist storage(含旧 RouteDock key 迁移) */ -export const appUiPersistStorage = createJSONStorage(() => appUiStorage); +const appUiAdapter: LegacyAdapter = { + legacyValue: legacyDockValue, + clearLegacy: () => { + localStorage.removeItem(LEGACY_DOCK_STORAGE_KEY); + }, +}; + +export const appUiPersistStorage = createJSONStorage(() => + legacyFallbackStorage(appUiAdapter), +); + +/** 会话状态迁移:读取 v1 envelope 或直接 partial state */ +export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState { + if (isRecord(persisted) && isRecord(persisted.state)) { + const state = persisted.state as Record; + return { + token: typeof state.token === 'string' ? state.token : null, + user: isRecord(state.user) ? (state.user as UserInfo) : null, + }; + } + const existing = (isRecord(persisted) ? persisted : {}) as Partial; + return { + token: existing.token ?? null, + user: existing.user ?? null, + }; +} + +/** 权限状态迁移:兼容 v1 envelope 与裸数组,恢复后一律 fail-closed */ +export function migratePermissionState( + persisted: unknown, + _version: number, +): PermissionPersistedState { + if (isRecord(persisted) && isRecord(persisted.state)) { + const permissions = Array.isArray(persisted.state.permissions) + ? persisted.state.permissions.filter(isString) + : []; + return { permissions }; + } + if (Array.isArray(persisted)) { + return { permissions: persisted.filter(isString) }; + } + const existing = (isRecord(persisted) ? persisted : {}) as Partial; + return { + permissions: Array.isArray(existing.permissions) ? existing.permissions.filter(isString) : [], + }; +} + +/** 应用 UI 状态迁移:读取 v1 envelope 或直接 partial state */ +export function migrateAppUiState(persisted: unknown, _version: number): AppPersistedState { + if (isRecord(persisted) && isRecord(persisted.state)) { + const state = persisted.state as Record; + return { + routeDockTabs: Array.isArray(state.routeDockTabs) + ? state.routeDockTabs.filter(isDockTab) + : [], + sidebarCollapsed: state.sidebarCollapsed === true, + }; + } + const existing = (isRecord(persisted) ? persisted : {}) as Partial; + return { + routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [], + sidebarCollapsed: existing.sidebarCollapsed ?? false, + }; +} export type { AppPersistedState, PermissionPersistedState, UserPersistedState }; diff --git a/apps/admin/src/store/permission/permissionStore.ts b/apps/admin/src/store/permission/permissionStore.ts index d288f24..0999c7b 100644 --- a/apps/admin/src/store/permission/permissionStore.ts +++ b/apps/admin/src/store/permission/permissionStore.ts @@ -1,8 +1,9 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { - permissionPersistStorage, PERMISSION_STORAGE_NAME, + migratePermissionState, + permissionPersistStorage, } from '../middleware/persist'; import type { PermissionPersistedState, PermissionStore } from './permissionTypes'; @@ -40,7 +41,8 @@ export const usePermissionStore = create()( name: PERMISSION_STORAGE_NAME, storage: permissionPersistStorage, partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }), - version: 1, + version: 2, + migrate: migratePermissionState, }, ), { name: 'permission-store', enabled: import.meta.env.DEV }, diff --git a/apps/admin/src/store/settings/settingsStore.ts b/apps/admin/src/store/settings/settingsStore.ts index ee18275..e349879 100644 --- a/apps/admin/src/store/settings/settingsStore.ts +++ b/apps/admin/src/store/settings/settingsStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; -import { createJSONStorage } from 'zustand/middleware'; +import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { SETTINGS_STORAGE_NAME } from '../middleware/persist'; import type { SettingsState, SettingsStore } from './settingsTypes'; diff --git a/apps/admin/src/store/types.ts b/apps/admin/src/store/types.ts index d967265..b3c34b7 100644 --- a/apps/admin/src/store/types.ts +++ b/apps/admin/src/store/types.ts @@ -9,6 +9,3 @@ /** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */ export type StoreStatus = 'unknown' | 'loading' | 'ready'; - -/** 持久化时从 Store 中挑选出的字段 */ -export type Partialize = (state: T) => Partial; diff --git a/apps/admin/src/store/user/userStore.ts b/apps/admin/src/store/user/userStore.ts index 8bf8ef0..ccb0ecc 100644 --- a/apps/admin/src/store/user/userStore.ts +++ b/apps/admin/src/store/user/userStore.ts @@ -1,13 +1,13 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; -import { authPersistStorage, AUTH_STORAGE_NAME } from '../middleware/persist'; +import { AUTH_STORAGE_NAME, authPersistStorage, migrateAuthState } from '../middleware/persist'; import { createUserActions } from './userActions'; import type { UserPersistedState, UserStore } from './userTypes'; /** * 用户会话 Store(token + 用户资料)。 * 使用 zustand 官方推荐写法:create()(devtools(persist(...)))。 - * 持久化沿用旧 `token` / `user` localStorage key。 + * 持久化使用官方 persist + localStorage,旧 `token` / `user` key 由 migrate 一次性迁移。 */ export const useUserStore = create()( devtools( @@ -21,7 +21,8 @@ export const useUserStore = create()( name: AUTH_STORAGE_NAME, storage: authPersistStorage, partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }), - version: 1, + version: 2, + migrate: migrateAuthState, }, ), { name: 'user-store', enabled: import.meta.env.DEV }, diff --git a/apps/admin/src/test/fixtures.ts b/apps/admin/src/test/fixtures.ts deleted file mode 100644 index a8bc120..0000000 --- a/apps/admin/src/test/fixtures.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Test fixtures — consistent test data used across integration tests. - * - * These mirror the PRD data models and are used to seed/verify API responses. - * All IDs are prefixed "test-" to distinguish from real data in a shared dev DB. - */ - -// ── Auth ──────────────────────────────────────────────────────────── - -export const CREDENTIALS = { - superAdmin: { username: 'admin', password: 'admin123' }, - staff: { username: 'staff1', password: 'staff123' }, - classTeacher: { username: 'teacher1', password: 'teacher123' }, - student: { username: 'student1', password: 'student123' }, -} as const; - -// ── Student (PRD §3) ──────────────────────────────────────────────── - -export const SAMPLE_STUDENT = { - name: '测试学员A', - phone: '13800000001', - idCard: '110101200001011234', - gender: '男', - ethnicity: '汉族', - status: 'active', - emergencyContact: '张三', - emergencyPhone: '13900000001', - studentNo: 'TEST-2026-001', -}; - -export const SAMPLE_STUDENT_B = { - name: '测试学员B', - phone: '13800000002', - idCard: '110101200001011235', - gender: '女', - ethnicity: '汉族', - status: 'active', - emergencyContact: '李四', - emergencyPhone: '13900000002', - studentNo: 'TEST-2026-002', -}; - -// ── Class (PRD §5) ────────────────────────────────────────────────── - -export const SAMPLE_CLASS = { - name: '2026届文化课冲刺1班', - code: 'TEST-WHK-2026-001', - classType: '文化课', - startDate: '2026-03-01', - endDate: '2026-06-30', - status: '在读', - maxStudents: 40, -}; - -// ── Schedule (PRD §6) ─────────────────────────────────────────────── - -export const SAMPLE_SCHEDULE = { - weekDay: 1, // 周一 - startTime: '09:00', - endTime: '10:30', - subject: '语文', - scheduleType: 'INTERNAL', - status: 'active', -}; - -// Conflicting schedule: same classroom, same weekday, overlapping time -export const CONFLICT_SCHEDULE = { - weekDay: 1, - startTime: '09:30', // overlaps with 09:00-10:30 - endTime: '11:00', - subject: '数学', - scheduleType: 'INTERNAL', - status: 'active', -}; - -// Non-conflicting: same classroom, same weekday, non-overlapping -export const NON_CONFLICT_SCHEDULE = { - weekDay: 1, - startTime: '10:30', // exactly at boundary — no overlap - endTime: '12:00', - subject: '英语', - scheduleType: 'INTERNAL', - status: 'active', -}; - -// ── Room / Dormitory (PRD §7) ─────────────────────────────────────── - -export const SAMPLE_ROOM = { - roomNumber: 'TEST-401', - building: '1号楼', - floor: 4, - capacity: 6, - status: 'available', - gender: '男', - rentalCategory: 'short', - roomType: '标准间', -}; - -export const SAMPLE_LONG_RENT_ROOM = { - roomNumber: 'TEST-501', - building: '1号楼', - floor: 5, - capacity: 4, - status: 'available', - gender: '女', - rentalCategory: 'long', - monthlyRate: 800, - roomType: '标准间', -}; - -// ── Occupancy (PRD §8) ────────────────────────────────────────────── - -export const SAMPLE_OCCUPANCY = { - checkInDate: '2026-03-01', - billingStartDate: '2026-03-01', - billingEndDate: '2026-06-30', - stayType: 'short', -}; - -// ── Bill / Expense (PRD §9-10) ────────────────────────────────────── - -export const SAMPLE_EXPENSE = { - type: 'water', - amount: 150.0, - billingMonth: '2026-03', - description: '3月水费公摊', -}; - -export const SAMPLE_PERSONAL_EXPENSE = { - type: 'damage', - amount: 50.0, - description: '损坏赔偿-台灯', -}; - -// ── Deposit (PRD §11) ─────────────────────────────────────────────── - -export const SAMPLE_DEPOSIT = { - amount: 500.0, - type: 'collect' as const, - notes: '入学押金', -}; - -// ── Attendance (PRD §13) ──────────────────────────────────────────── - -export const SAMPLE_ATTENDANCE = { - attendanceDate: '2026-03-15', - session: '上午', - status: '出勤', - source: '人工点名', - courseName: '语文', -}; - -export const SAMPLE_ATTENDANCE_ABSENT = { - attendanceDate: '2026-03-16', - session: '上午', - status: '缺勤', - source: '人工点名', - courseName: '语文', -}; - -// ── Classroom (PRD §6) ────────────────────────────────────────────── - -export const SAMPLE_CLASSROOM = { - name: 'TEST-301教室', - building: '教学楼A', - floor: 3, - capacity: 50, - roomType: '大', - status: 'available', -}; - -// ── Organization (PRD §12) ──────────────────────────────────────────────── - -export const SAMPLE_TENANT = { - name: '测试合作机构A', - contact: '王经理', - phone: '13700000001', - color: '#1890ff', - status: 'active', -}; - -// ── Operation Log expectation (PRD §18) ───────────────────────────── - -export const LOG_ACTIONS = { - STUDENT_CREATE: { module: 'students', action: 'create' }, - STUDENT_UPDATE: { module: 'students', action: 'update' }, - STUDENT_DELETE: { module: 'students', action: 'delete' }, - BILL_GENERATE: { module: 'bills', action: 'generate' }, - BILL_CONFIRM: { module: 'bills', action: 'confirm' }, - DEPOSIT_COLLECT: { module: 'deposits', action: 'collect' }, - DEPOSIT_REFUND: { module: 'deposits', action: 'refund' }, - OCCUPANCY_CHECKIN: { module: 'occupancies', action: 'create' }, - OCCUPANCY_CHECKOUT: { module: 'occupancies', action: 'checkout' }, - EXPENSE_CREATE: { module: 'expenses', action: 'create' }, - CLASS_CREATE: { module: 'classes', action: 'create' }, - CLASS_DELETE: { module: 'classes', action: 'delete' }, - SCHEDULE_CREATE: { module: 'schedules', action: 'create' }, - ATTENDANCE_BATCH: { module: 'attendance', action: 'batch' }, - SENSITIVE_VIEW: { module: 'students', action: 'view_sensitive' }, -} as const; - -// ── Permission nodes (PRD §17) ────────────────────────────────────── - -export const PERMISSION_NODES = [ - 'student:view', - 'student:add', - 'student:update', - 'student:delete', - 'student:import', - 'student:export', - 'room:view', - 'room:add', - 'room:update', - 'room:delete', - 'occupancy:view', - 'occupancy:add', - 'occupancy:update', - 'bill:view', - 'bill:generate', - 'bill:confirm', - 'bill:markPaid', - 'bill:export', - 'expense:view', - 'expense:add', - 'expense:update', - 'expense:delete', - 'deposit:view', - 'deposit:collect', - 'deposit:refund', - 'class:view', - 'class:add', - 'class:update', - 'class:delete', - 'schedule:view', - 'schedule:add', - 'schedule:update', - 'schedule:delete', - 'attendance:view', - 'attendance:add', - 'attendance:update', - 'attendance:delete', - 'attendance:batch', - 'classroom:view', - 'classroom:add', - 'classroom:update', - 'classroom:delete', - 'organization:view', - 'organization:create', - 'organization:edit', - 'organization:delete', - 'rental:view', - 'rental:add', - 'rental:update', - 'rental:delete', - 'archive:view', - 'archive:import', - 'archive:export', - 'report:generate', - 'log:view', - 'role:view', - 'role:add', - 'role:update', - 'role:delete', - 'user:view', - 'user:add', - 'user:update', - 'dashboard:view', -] as const; diff --git a/apps/admin/src/test/helpers.ts b/apps/admin/src/test/helpers.ts deleted file mode 100644 index 2797155..0000000 --- a/apps/admin/src/test/helpers.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Shared browser-test helpers. - * - * Import this in every `*.integration.test.ts` file. - * Provides login, API calling, and page-navigation utilities - * that work inside the Vitest browser environment. - */ -import { expect } from 'vitest'; -import { CREDENTIALS } from './fixtures'; -import { BASE } from './setup'; -import { usePermissionStore } from '../store/permission/permissionStore'; -import { useUserStore } from '../store/user/userStore'; -import type { UserInfo } from '../store/user/userTypes'; - -// ── Types ─────────────────────────────────────────────────────────── - -interface ApiResponse { - code: number; - data: T; - message?: string; -} - -type Role = keyof typeof CREDENTIALS; - -// ── Auth helpers ──────────────────────────────────────────────────── - -/** - * Login as a specific role and store the token in localStorage. - * Returns the parsed response data. - */ -export async function loginAs( - role: Role, -): Promise<{ token: string; user: Record }> { - const creds = CREDENTIALS[role]; - const res = await fetch(`${BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(creds), - }); - expect(res.status).toBe(201); - const json = (await res.json()) as ApiResponse<{ token: string; user: Record }>; - expect(json.code).toBe(0); - useUserStore.getState().setSession(json.data.token, json.data.user as unknown as UserInfo); - usePermissionStore - .getState() - .writePermissions((json.data.user.permissions ?? []) as string[]); - return json.data; -} - -/** - * Logout: clear localStorage. - */ -export function logout(): void { - useUserStore.getState().logout(); - usePermissionStore.getState().clearPermissions(); -} - -// ── API helpers (authenticated) ───────────────────────────────────── - -function authHeaders(): Record { - const token = useUserStore.getState().token; - return { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; -} - -export async function apiGet(url: string): Promise> { - const res = await fetch(`${BASE}${url}`, { headers: authHeaders() }); - return (await res.json()) as ApiResponse; -} - -export async function apiPost(url: string, body?: unknown): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'POST', - headers: authHeaders(), - body: body ? JSON.stringify(body) : undefined, - }); - return (await res.json()) as ApiResponse; -} - -export async function apiPut(url: string, body?: unknown): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'PUT', - headers: authHeaders(), - body: body ? JSON.stringify(body) : undefined, - }); - return (await res.json()) as ApiResponse; -} - -export async function apiDelete(url: string): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'DELETE', - headers: authHeaders(), - }); - return (await res.json()) as ApiResponse; -} - -// ── Page helpers ──────────────────────────────────────────────────── - -/** - * Navigate to a page and wait for it to load. - */ -export async function goTo(path: string): Promise { - document.location.href = `${BASE}${path}`; - // Wait for React to render - await new Promise((r) => setTimeout(r, 500)); -} - -/** - * Assert the current page URL contains the given path. - */ -export async function assertOnPage(path: string): Promise { - // Wait a tick for SPA routing - await new Promise((r) => setTimeout(r, 300)); - expect(window.location.pathname).toContain(path); -} - -// ── Wait helpers ──────────────────────────────────────────────────── - -/** Poll until a condition is true or timeout. */ -export async function waitFor( - condition: () => boolean | Promise, - timeout = 5000, - interval = 200, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - if (await condition()) return; - await new Promise((r) => setTimeout(r, interval)); - } - throw new Error(`waitFor timed out after ${timeout}ms`); -} - -// ── Assertion helpers ─────────────────────────────────────────────── - -/** Assert an API response is successful (code === 0). */ -export function assertOk(res: ApiResponse, msg?: string): T { - expect(res.code, msg ?? 'API should return code 0').toBe(0); - return res.data; -} - -/** Assert an API response is an error (code !== 0). */ -export function assertError(res: ApiResponse, expectedCode?: number): void { - expect(res.code).not.toBe(0); - if (expectedCode !== undefined) { - expect(res.code).toBe(expectedCode); - } -} - -/** Assert a 403 is returned (permission denied). */ -export async function assertForbidden(promise: Promise): Promise { - const res = await promise; - expect(res.status).toBe(403); -} - -/** Assert a 401 is returned (unauthenticated). */ -export async function assertUnauthenticated(promise: Promise): Promise { - const res = await promise; - expect(res.status).toBe(401); -} - -// ── Sensitive data helpers (PRD §3.3) ─────────────────────────────── - -/** Assert phone number is masked: 138****0001 */ -export function assertPhoneMasked(displayed: string): void { - expect(displayed).toMatch(/^\d{3}\*{4}\d{4}$/); -} - -/** Assert ID card is masked: 110101********1234 */ -export function assertIdCardMasked(displayed: string): void { - expect(displayed).toMatch(/^\d{6}\*{8}\d{4}$/); -} diff --git a/apps/admin/src/utils/download.ts b/apps/admin/src/utils/download.ts index d95d0db..3c87aea 100644 --- a/apps/admin/src/utils/download.ts +++ b/apps/admin/src/utils/download.ts @@ -1,4 +1,5 @@ import { useUserStore } from '../store/user/userStore'; +import { saveAs } from 'file-saver'; /** * Download a file from the API as a blob and trigger a browser download. @@ -22,12 +23,5 @@ export async function downloadBlob(endpoint: string, filename: string): Promise< } const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + saveAs(blob, filename); } diff --git a/apps/admin/src/utils/error.ts b/apps/admin/src/utils/error.ts new file mode 100644 index 0000000..e7da39d --- /dev/null +++ b/apps/admin/src/utils/error.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; + +/** + * 统一从任意错误对象中提取可展示的 message。 + * 后端 4xx/5xx 经 axios 拦截器解包后通常是 { message } 普通对象; + * 网络/超时/取消则是 axios 原生错误。 + */ +export function getErrorMessage(error: unknown, fallback = '操作失败'): string { + let message = ''; + if (axios.isAxiosError(error)) { + const data = error.response?.data as { message?: unknown } | undefined; + if (typeof data?.message === 'string' && data.message) message = data.message; + else if (error.message) message = error.message; + } else if (error && typeof error === 'object' && 'message' in error) { + const value = (error as { message?: unknown }).message; + if (typeof value === 'string' && value) message = value; + } else if (typeof error === 'string' && error) { + message = error; + } + const trimmed = message.trim(); + if (!trimmed) return fallback; + const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' '); + return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine; +} diff --git a/apps/admin/src/utils/validate.ts b/apps/admin/src/utils/validate.ts new file mode 100644 index 0000000..69623e9 --- /dev/null +++ b/apps/admin/src/utils/validate.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +/** + * 用 zod schema 校验接口响应;失败时抛出带字段路径的错误, + * 由调用方的统一错误处理(getErrorMessage / useApiMutation)展示。 + */ +export function validateResponse(schema: z.ZodType, data: unknown): T { + const result = schema.safeParse(data); + if (!result.success) { + const first = result.error.issues[0]; + const path = first?.path?.join('.'); + console.error('[response-validation]', result.error.issues); + throw new Error(path ? `接口字段 ${path} 格式异常` : '接口数据格式异常'); + } + return result.data as T; +} From 68270e7571d7ba507921f50001d6f2093b5d96d2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:42 +0800 Subject: [PATCH 04/19] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=E5=9F=BA=E5=BB=BA=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/common/with-audit-log.ts | 63 ++ .../src/database/database-migrations.ai.ts | 88 ++ .../database-migrations.attendance.ts | 260 ++++++ .../database/database-migrations.backfill.ts | 180 ++++ .../database/database-migrations.runner.ts | 14 + .../database/database-migrations.schema.ts | 265 ++++++ .../database/database-migrations.service.ts | 831 +----------------- .../src/database/database-migrations.spec.ts | 154 ++-- .../src/migrations/1784780000000-AddAiChat.ts | 1 + ...784860000000-EnhanceAiChatForAntDesignX.ts | 1 + .../migrations/1784910000000-AddImportRuns.ts | 121 +++ .../1784920000000-DropAiMessageFeedback.ts | 28 + 12 files changed, 1161 insertions(+), 845 deletions(-) create mode 100644 apps/server/src/common/with-audit-log.ts create mode 100644 apps/server/src/database/database-migrations.ai.ts create mode 100644 apps/server/src/database/database-migrations.attendance.ts create mode 100644 apps/server/src/database/database-migrations.backfill.ts create mode 100644 apps/server/src/database/database-migrations.runner.ts create mode 100644 apps/server/src/database/database-migrations.schema.ts create mode 100644 apps/server/src/migrations/1784910000000-AddImportRuns.ts create mode 100644 apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts diff --git a/apps/server/src/common/with-audit-log.ts b/apps/server/src/common/with-audit-log.ts new file mode 100644 index 0000000..8362675 --- /dev/null +++ b/apps/server/src/common/with-audit-log.ts @@ -0,0 +1,63 @@ +import type { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { extractRequestInfo } from './request-utils'; + +export interface AuditRequestUser { + id?: number; + username?: string; +} + +export interface AuditRequest { + user?: AuditRequestUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +export interface AuditLogEntry { + module: string; + action: string; + targetId?: number; + targetType?: string; + detail?: string; + status?: string; +} + +/** + * 执行业务操作并写入一条审计日志。 + * 统一从请求中提取 IP / UA,避免每个 controller 重复这段样板。 + */ +export async function withAuditLog( + logService: OperationLogsService, + req: AuditRequest, + buildEntry: (result: T) => AuditLogEntry, + operation: () => Promise, +): Promise { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await operation(); + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...buildEntry(result), + }); + return result; +} + +/** + * 仅写入一条审计日志(不包装业务操作)。 + * 适用于日志发生在操作中间、后面还有其他逻辑的 handler。 + */ +export async function logAudit( + logService: OperationLogsService, + req: AuditRequest, + entry: AuditLogEntry, +): Promise { + const { ipAddress, userAgent } = extractRequestInfo(req); + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...entry, + }); +} diff --git a/apps/server/src/database/database-migrations.ai.ts b/apps/server/src/database/database-migrations.ai.ts new file mode 100644 index 0000000..77d77a8 --- /dev/null +++ b/apps/server/src/database/database-migrations.ai.ts @@ -0,0 +1,88 @@ +import { Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function ensureAiConfigTable( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['ai_config']); + const isMySQL = dataSource.options.type === 'mysql'; + + if (tables.length === 0) { + const pkDef = isMySQL + ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' + : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; + const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN'; + const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP'; + + await runner.query(` + CREATE TABLE ai_config ( + ${pkDef}, + singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL', + provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI', + base_url VARCHAR(500), + encrypted_api_key TEXT, + api_key_iv VARCHAR(50), + api_key_auth_tag VARCHAR(50), + key_last4 VARCHAR(4), + default_model VARCHAR(100), + enabled ${boolType} DEFAULT 0, + timeout_ms INT DEFAULT 30000, + verified ${boolType} DEFAULT 0, + last_tested_at DATETIME, + last_test_latency_ms INT, + created_at DATETIME NOT NULL DEFAULT ${datetimeFn}, + updated_at DATETIME NOT NULL DEFAULT ${datetimeFn} + ) + `); + + if (isMySQL) { + try { + await runner.query( + 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', + ); + } catch { + // Index may already exist; MySQL has no IF NOT EXISTS for indexes + } + } else { + await runner.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)', + ); + } + + logger.log('已创建 ai_config 表'); + } else { + const table = await runner.getTable('ai_config'); + const columnNames = new Set(table?.columns.map((c) => c.name) ?? []); + + const desiredColumns: Array<{ name: string; def: string }> = [ + { name: 'id', def: '' }, // skip — primary key + { name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" }, + { name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" }, + { name: 'base_url', def: 'VARCHAR(500)' }, + { name: 'encrypted_api_key', def: 'TEXT' }, + { name: 'api_key_iv', def: 'VARCHAR(50)' }, + { name: 'api_key_auth_tag', def: 'VARCHAR(50)' }, + { name: 'key_last4', def: 'VARCHAR(4)' }, + { name: 'default_model', def: 'VARCHAR(100)' }, + { name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'timeout_ms', def: 'INT DEFAULT 30000' }, + { name: 'reasoning_effort', def: 'VARCHAR(20)' }, + { name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'last_tested_at', def: 'DATETIME' }, + { name: 'last_test_latency_ms', def: 'INT' }, + { name: 'created_at', def: 'DATETIME' }, + { name: 'updated_at', def: 'DATETIME' }, + ]; + + for (const col of desiredColumns) { + if (col.def && !columnNames.has(col.name)) { + await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`); + logger.log(`已为 ai_config 表添加列: ${col.name}`); + } + } + } + }); +} diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts new file mode 100644 index 0000000..f76a7b2 --- /dev/null +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -0,0 +1,260 @@ +import { Logger } from '@nestjs/common'; +import { DataSource, QueryRunner } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export function attendanceSessionsDdl(tableName: string, idClause: string): string { + return ` + CREATE TABLE ${tableName} ( + ${idClause}, + schedule_id INTEGER NOT NULL, + class_id INTEGER NOT NULL, + lesson_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'in_progress', + started_by INTEGER, + started_at DATETIME, + completed_by INTEGER, + completed_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT + ) + `; +} + +export async function ensureCourseAttendanceSchema( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables([ + 'class_schedule', + 'attendance_records', + 'attendance_sessions', + ]); + const tableNames = new Set(tables.map((table) => table.name)); + const isMySQL = dataSource.options.type === 'mysql'; + + if (!tableNames.has('attendance_sessions')) { + const pkDef = isMySQL + ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' + : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; + await runner.query(attendanceSessionsDdl('attendance_sessions', pkDef)); + } + + if (tableNames.has('class_schedule')) { + const scheduleTable = await runner.getTable('class_schedule'); + const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []); + if (!scheduleColumns.has('attendance_advance_minutes')) { + await runner.query( + 'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30', + ); + logger.log('已为排课添加课前签到分钟配置'); + } + } + + const attendanceTable = await runner.getTable('attendance_records'); + const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); + if (!columnNames.has('schedule_id')) { + await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'); + } + if (!columnNames.has('attendance_session_id')) { + await runner.query( + 'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER', + ); + } + + const createIndex = async (sql: string) => { + try { + await runner.query(sql); + } catch { + // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. + } + }; + await createIndex( + isMySQL + ? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)' + : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', + ); + await createIndex( + isMySQL + ? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)' + : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', + ); + }); +} + +export async function protectAttendanceHistory( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['attendance_sessions']); + if (tables.length === 0) return; + + const isMySQL = dataSource.options.type === 'mysql'; + if (isMySQL) { + await migrateMySQLAttendanceFKs(runner, logger); + } else { + await migrateSQLiteAttendanceFKs(runner, logger); + } + }); +} + +export async function migrateMySQLAttendanceFKs( + runner: QueryRunner, + logger: Logger, +): Promise { + // Drop any existing FK constraint on schedule_id or class_id + const fkColumns = ['schedule_id', 'class_id']; + for (const col of fkColumns) { + const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( + ` + SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'attendance_sessions' + AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL + `, + [col], + ); + + for (const row of fkRows) { + try { + await runner.query( + `ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``, + ); + logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`); + } catch { + // constraint may have already been dropped + } + } + } + + const constraints: Array<{ name: string; col: string; ref: string }> = [ + { name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' }, + { name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' }, + ]; + for (const c of constraints) { + // Only skip if RESTRICT constraint is already confirmed via information_schema + const existing: Array<{ DELETE_RULE: string }> = await runner.query( + ` + SELECT DELETE_RULE + FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'attendance_sessions' + AND CONSTRAINT_NAME = ? + `, + [c.name], + ); + + if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { + logger.log(`考勤场次删除保护约束已存在: ${c.name}`); + continue; + } + + // ADD RESTRICT must throw on failure — no catch + await runner.query(` + ALTER TABLE attendance_sessions + ADD CONSTRAINT ${c.name} + FOREIGN KEY (${c.col}) REFERENCES ${c.ref} + ON DELETE RESTRICT + `); + logger.log(`已添加考勤场次删除保护约束: ${c.name}`); + } +} + +export async function migrateSQLiteAttendanceFKs( + runner: QueryRunner, + logger: Logger, +): Promise { + // SQLite cannot ALTER TABLE to add foreign keys. + // Rebuild the table inside a transaction: create a new table with FK constraints, + // copy all rows, drop old, rename new, then recreate indexes. + const fkRows: Array<{ id: number }> = await runner.query( + "PRAGMA foreign_key_list('attendance_sessions')", + ); + if (fkRows.length > 0) return; // FKs already present + + logger.log('正在重建 attendance_sessions 表以添加外键保护…'); + + // PRAGMA foreign_keys=OFF must be issued outside the transaction + await runner.query('PRAGMA foreign_keys = OFF'); + try { + await runner.query('BEGIN'); + try { + await runner.query(attendanceSessionsDdl('attendance_sessions_new', 'id INTEGER PRIMARY KEY AUTOINCREMENT')); + await runner.query(` + INSERT INTO attendance_sessions_new ( + id, schedule_id, class_id, lesson_date, status, + started_by, started_at, completed_by, completed_at, created_at, updated_at + ) + SELECT + id, schedule_id, class_id, lesson_date, status, + started_by, started_at, completed_by, completed_at, created_at, updated_at + FROM attendance_sessions + `); + await runner.query('DROP TABLE attendance_sessions'); + await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); + await runner.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', + ); + + // Rebuild attendance_records to add/protect FK on attendance_session_id + const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); + const hasSessionFk = recordsFk.some( + (r: { from: string }) => r.from === 'attendance_session_id', + ); + if (!hasSessionFk) { + await runner.query(` + CREATE TABLE attendance_records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER NOT NULL, + class_id INTEGER, + schedule_id INTEGER, + attendance_session_id INTEGER, + attendance_date DATE NOT NULL, + session VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + remark VARCHAR(200), + source VARCHAR(20) DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL + ) + `); + await runner.query(` + INSERT INTO attendance_records_new ( + id, student_id, class_id, schedule_id, attendance_session_id, + attendance_date, session, status, remark, source, created_at, updated_at + ) + SELECT + id, student_id, class_id, schedule_id, attendance_session_id, + attendance_date, session, status, remark, source, created_at, updated_at + FROM attendance_records + `); + await runner.query('DROP TABLE attendance_records'); + await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); + await runner.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', + ); + } + + // Verify foreign key integrity BEFORE committing the transaction. + // If violations exist, the transaction rolls back and old tables are preserved. + const checkRows = await runner.query('PRAGMA foreign_key_check'); + if (checkRows.length > 0) { + throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); + } + + await runner.query('COMMIT'); + logger.log('attendance_sessions 表外键保护重建完成'); + } catch (err) { + await runner.query('ROLLBACK'); + throw err; + } + } finally { + await runner.query('PRAGMA foreign_keys = ON'); + } +} diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts new file mode 100644 index 0000000..d5fbf67 --- /dev/null +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -0,0 +1,180 @@ +import { Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { uuidV7 } from '../common/uuid-v7'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function backfillOrganizations( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables([ + 'tenants', + 'organizations', + 'students', + 'occupancies', + 'classroom_rentals', + ]); + const tableNames = new Set(tables.map((table) => table.name)); + if (!tableNames.has('organizations')) return; + + const organizationRows = () => + runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1'); + let host = (await organizationRows())[0]; + if (!host) { + await runner.query( + `INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + [ + uuidV7(), + 'HOST', + process.env.HOST_ORGANIZATION_NAME || '本机构', + 1, + '#1677ff', + '系统默认运营主体', + 'active', + ], + ); + host = (await organizationRows())[0]; + } + if (!host) return; + + if (tableNames.has('tenants')) { + const legacyTenants: Array> = + await runner.query('SELECT * FROM tenants'); + for (const legacy of legacyTenants) { + const name = String(legacy.name || '').trim(); + if (!name) continue; + let external = ( + await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) + )[0]; + if (!external) { + await runner.query( + `INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + [ + uuidV7(), + `ORG_${legacy.id}`, + name, + 0, + legacy.contact || null, + legacy.phone || null, + legacy.color || null, + legacy.notes || null, + legacy.status || 'active', + ], + ); + external = ( + await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) + )[0]; + } + if (!external) continue; + if (tableNames.has('students')) { + await runner + .query( + 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?', + [external.id, legacy.id], + ) + .catch(() => undefined); + } + if (tableNames.has('occupancies')) { + await runner + .query( + 'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?', + [external.id, legacy.id], + ) + .catch(() => undefined); + } + if (tableNames.has('classroom_rentals')) { + await runner + .query( + 'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?', + [external.id, host.id, legacy.id], + ) + .catch(() => undefined); + } + } + } + + if (tableNames.has('students')) { + await runner.query( + 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL', + [host.id], + ); + } + if (tableNames.has('occupancies')) { + await runner.query( + `UPDATE occupancies + SET responsible_organization_id = COALESCE( + (SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ? + ) + WHERE responsible_organization_id IS NULL`, + [host.id], + ); + } + if (tableNames.has('classroom_rentals')) { + await runner.query( + 'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL', + [host.id], + ); + } + }); +} + +export async function normalizeClassDates( + dataSource: DataSource, + logger: Logger, +): Promise { + const driver = dataSource.options.type; + let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; + + await withQueryRunner(dataSource, async (runner) => { + const table = await runner.getTable('classes'); + if (!table) return; + + // Fresh MySQL schemas created by TypeORM already use native DATE columns. + // This cleanup is only for legacy schemas that stored dates as strings; + // comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE + // in strict SQL mode. + if (driver === 'mysql') { + columns = columns.filter((columnName) => { + const column = table.columns.find((item) => item.name === columnName); + const type = String(column?.type ?? '').toLowerCase(); + return !['date', 'datetime', 'timestamp'].includes(type); + }); + if (columns.length === 0) return; + } + }); + + const columnText = (column: string) => + driver === 'mysql' ? `CAST(${column} AS CHAR)` : column; + const firstTenChars = (column: string) => + driver === 'mysql' + ? `NULLIF(LEFT(${columnText(column)}, 10), '')` + : `NULLIF(substr(${column}, 1, 10), '')`; + const normalizedDate = (column: string) => `CASE + WHEN ${column} IS NULL THEN NULL + ELSE ${firstTenChars(column)} + END`; + const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; + const needsNormalization = (column: string) => `( + ${column} IS NOT NULL + AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10) + )`; + + const assignments = columns + .map((column) => `${column} = ${normalizedDate(column)}`) + .join(',\n '); + const predicates = columns.map((column) => needsNormalization(column)).join('\n OR '); + const result = await dataSource.transaction((manager) => + manager.query(` + UPDATE classes + SET + ${assignments} + WHERE + ${predicates} + `), + ); + + const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows; + if (affected) logger.log(`已规范化 ${affected} 条班级日期数据`); +} diff --git a/apps/server/src/database/database-migrations.runner.ts b/apps/server/src/database/database-migrations.runner.ts new file mode 100644 index 0000000..9e4792c --- /dev/null +++ b/apps/server/src/database/database-migrations.runner.ts @@ -0,0 +1,14 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +export async function withQueryRunner( + dataSource: DataSource, + fn: (runner: QueryRunner) => Promise, +): Promise { + const runner = dataSource.createQueryRunner(); + await runner.connect(); + try { + return await fn(runner); + } finally { + await runner.release(); + } +} diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts new file mode 100644 index 0000000..fd8d271 --- /dev/null +++ b/apps/server/src/database/database-migrations.schema.ts @@ -0,0 +1,265 @@ +import { DataSource } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function ensureSyncStateLeaseColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const table = await runner.getTable('sync_state'); + if (!table) return; + const columns = new Set(table.columns.map((column) => column.name)); + if (!columns.has('run_id')) { + await runner.query('ALTER TABLE sync_state ADD COLUMN run_id VARCHAR(64)'); + } + if (!columns.has('running_since')) { + await runner.query('ALTER TABLE sync_state ADD COLUMN running_since DATETIME'); + } + }); +} + +export async function ensureStudentProfileCollegeColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const table = await runner.getTable('student_profiles'); + if (!table) return; + const columns = new Set(table.columns.map((column) => column.name)); + const additions: Array<[string, string]> = [ + ['college_school', 'VARCHAR(100)'], + ['college_major', 'VARCHAR(100)'], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`); + } + }); +} + +export async function ensureAttendanceDevicesSchema( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const isMySQL = dataSource.options.type === 'mysql'; + const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices ( + id ${pk}, + device_sn VARCHAR(100) NOT NULL, + device_name VARCHAR(100) NOT NULL, + classroom_id INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + location VARCHAR(200), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + const table = await runner.getTable('attendance_devices'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + const additions: Array<[string, string]> = [ + ['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''], + ['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''], + ['classroom_id', 'INTEGER NOT NULL DEFAULT 0'], + ['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"], + ['location', 'VARCHAR(200)'], + ['notes', 'TEXT'], + ['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], + ['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], + ]; + for (const [name, definition] of additions) { + if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`); + } + const refreshed = await runner.getTable('attendance_devices'); + const createIndex = async (sql: string) => { + try { + await runner.query(sql); + } catch { + // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. + } + }; + const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique); + if (!uniqueSn) { + await createIndex( + isMySQL + ? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)' + : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)', + ); + } + await createIndex( + isMySQL + ? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)' + : 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', + ); + }); +} + +export async function ensureStudentWalletSchema( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const isMySQL = dataSource.options.type === 'mysql'; + const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets ( + id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions ( + id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL, + amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL, + description VARCHAR(300), recorded_by INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations ( + id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + const walletTransactions = await runner.getTable('wallet_transactions'); + if (walletTransactions) { + const columns = new Set(walletTransactions.columns.map((column) => column.name)); + if (!columns.has('operation_id')) { + await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)'); + } + } + const billItems = await runner.getTable('bill_items'); + if (billItems) { + const columns = new Set(billItems.columns.map((column) => column.name)); + for (const [name, definition] of [ + ['room_expense_id', 'INTEGER'], + ['personal_expense_id', 'INTEGER'], + ]) { + if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`); + } + } + const roomExpenses = await runner.getTable('room_expenses'); + if (roomExpenses) { + const columns = new Set(roomExpenses.columns.map((column) => column.name)); + if (!columns.has('import_key')) { + await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)'); + } + const refreshedRoomExpenses = await runner.getTable('room_expenses'); + const hasImportKey = refreshedRoomExpenses?.indices.some((index) => + index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); + if (!hasImportKey) { + await runner.query(isMySQL + ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' + : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); + } + } + const bills = await runner.getTable('bills'); + if (bills) { + const columns = new Set(bills.columns.map((column) => column.name)); + const additions = [ + ['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"], + ['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], + ['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], + ['cancelled_at', 'DATETIME'], + ['cancel_reason', 'VARCHAR(300)'], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`); + } + await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'"); + await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'"); + await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')"); + } + const personalExpenses = await runner.getTable('personal_expenses'); + if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) { + await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER'); + } + }); +} + +export async function removeUnusedClassroomColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['classrooms']); + if (tables.length === 0) return; + + const table = await runner.getTable('classrooms'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + for (const columnName of ['course_type', 'supervisor']) { + if (columnNames.has(columnName)) { + await runner.query(`ALTER TABLE classrooms DROP COLUMN ${columnName}`); + } + } + }); +} + +export async function removeUnusedRoomColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['rooms']); + if (tables.length === 0) return; + + const table = await runner.getTable('rooms'); + if (table?.columns.some((column) => column.name === 'gender')) { + await runner.dropColumn('rooms', 'gender'); + } + }); +} + +export async function cleanupDepositRefundColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['deposits']); + if (tables.length === 0) return; + + const table = await runner.getTable('deposits'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + for (const [legacyName, currentName] of [ + ['refund_approved_by', 'refunded_by'], + ['refund_approved_at', 'refunded_at'], + ] as const) { + if (!columnNames.has(legacyName)) continue; + + if (columnNames.has(currentName)) { + await runner.query( + `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`, + ); + await runner.dropColumn('deposits', legacyName); + } else { + await runner.renameColumn('deposits', legacyName, currentName); + columnNames.add(currentName); + } + columnNames.delete(legacyName); + } + + for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) { + if (columnNames.has(columnName)) { + await runner.dropColumn('deposits', columnName); + columnNames.delete(columnName); + } + } + }); +} + +export async function removeUnusedClassStudentColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['class_student']); + if (tables.length === 0) return; + + const table = await runner.getTable('class_student'); + if (table?.columns.some((column) => column.name === 'enrollment_id')) { + await runner.dropColumn('class_student', 'enrollment_id'); + } + }); +} + +export async function normalizeClassroomStatuses( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['classrooms']); + if (tables.length === 0) return; + await runner.query(` + UPDATE classrooms + SET status = 'available' + WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived') + `); + }); +} diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index d7290d2..2b601aa 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -1,6 +1,22 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; -import { DataSource, QueryRunner } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { DataSource } from 'typeorm'; +import { + cleanupDepositRefundColumns, + ensureAttendanceDevicesSchema, + ensureStudentProfileCollegeColumns, + ensureStudentWalletSchema, + ensureSyncStateLeaseColumns, + normalizeClassroomStatuses, + removeUnusedClassroomColumns, + removeUnusedClassStudentColumns, + removeUnusedRoomColumns, +} from './database-migrations.schema'; +import { ensureAiConfigTable } from './database-migrations.ai'; +import { + ensureCourseAttendanceSchema, + protectAttendanceHistory, +} from './database-migrations.attendance'; +import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill'; @Injectable() export class DatabaseMigrationsService implements OnApplicationBootstrap { @@ -25,814 +41,59 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.normalizeClassroomStatuses(); } - private async ensureSyncStateLeaseColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const table = await runner.getTable('sync_state'); - if (!table) return; - const columns = new Set(table.columns.map((column) => column.name)); - if (!columns.has('run_id')) { - await runner.query('ALTER TABLE sync_state ADD COLUMN run_id VARCHAR(64)'); - } - if (!columns.has('running_since')) { - await runner.query('ALTER TABLE sync_state ADD COLUMN running_since DATETIME'); - } - } finally { - await runner.release(); - } + async ensureSyncStateLeaseColumns(): Promise { + return ensureSyncStateLeaseColumns(this.dataSource); } - private async ensureStudentProfileCollegeColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const table = await runner.getTable('student_profiles'); - if (!table) return; - const columns = new Set(table.columns.map((column) => column.name)); - const additions: Array<[string, string]> = [ - ['college_school', 'VARCHAR(100)'], - ['college_major', 'VARCHAR(100)'], - ]; - for (const [name, definition] of additions) { - if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`); - } - } finally { - await runner.release(); - } + async ensureStudentProfileCollegeColumns(): Promise { + return ensureStudentProfileCollegeColumns(this.dataSource); } - private async ensureAttendanceDevicesSchema(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const isMySQL = this.dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; - await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices ( - id ${pk}, - device_sn VARCHAR(100) NOT NULL, - device_name VARCHAR(100) NOT NULL, - classroom_id INTEGER NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'active', - location VARCHAR(200), - notes TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`); - const table = await runner.getTable('attendance_devices'); - const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); - const additions: Array<[string, string]> = [ - ['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''], - ['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''], - ['classroom_id', 'INTEGER NOT NULL DEFAULT 0'], - ['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"], - ['location', 'VARCHAR(200)'], - ['notes', 'TEXT'], - ['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], - ['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], - ]; - for (const [name, definition] of additions) { - if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`); - } - const refreshed = await runner.getTable('attendance_devices'); - const createIndex = async (sql: string) => { - try { - await runner.query(sql); - } catch { - // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. - } - }; - const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique); - if (!uniqueSn) { - await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)', - ); - } - await createIndex( - isMySQL - ? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)' - : 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', - ); - } finally { - await runner.release(); - } + async ensureAttendanceDevicesSchema(): Promise { + return ensureAttendanceDevicesSchema(this.dataSource); } - private async ensureStudentWalletSchema(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const isMySQL = this.dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; - await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets ( - id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`); - await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions ( - id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL, - amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL, - description VARCHAR(300), recorded_by INTEGER, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`); - await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations ( - id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`); - const walletTransactions = await runner.getTable('wallet_transactions'); - if (walletTransactions) { - const columns = new Set(walletTransactions.columns.map((column) => column.name)); - if (!columns.has('operation_id')) { - await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)'); - } - } - const billItems = await runner.getTable('bill_items'); - if (billItems) { - const columns = new Set(billItems.columns.map((column) => column.name)); - for (const [name, definition] of [ - ['room_expense_id', 'INTEGER'], - ['personal_expense_id', 'INTEGER'], - ]) { - if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`); - } - } - const roomExpenses = await runner.getTable('room_expenses'); - if (roomExpenses) { - const columns = new Set(roomExpenses.columns.map((column) => column.name)); - if (!columns.has('import_key')) { - await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)'); - } - const refreshedRoomExpenses = await runner.getTable('room_expenses'); - const hasImportKey = refreshedRoomExpenses?.indices.some((index) => - index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); - if (!hasImportKey) { - await runner.query(isMySQL - ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); - } - } - const bills = await runner.getTable('bills'); - if (bills) { - const columns = new Set(bills.columns.map((column) => column.name)); - const additions = [ - ['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"], - ['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], - ['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], - ['cancelled_at', 'DATETIME'], - ['cancel_reason', 'VARCHAR(300)'], - ]; - for (const [name, definition] of additions) { - if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`); - } - await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'"); - await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'"); - await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')"); - } - const personalExpenses = await runner.getTable('personal_expenses'); - if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) { - await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER'); - } - } finally { - await runner.release(); - } + async ensureStudentWalletSchema(): Promise { + return ensureStudentWalletSchema(this.dataSource); } - private async removeUnusedClassroomColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['classrooms']); - if (tables.length === 0) return; - - const table = await runner.getTable('classrooms'); - const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); - for (const columnName of ['course_type', 'supervisor']) { - if (columnNames.has(columnName)) { - await runner.query(`ALTER TABLE classrooms DROP COLUMN ${columnName}`); - } - } - } finally { - await runner.release(); - } + async removeUnusedClassroomColumns(): Promise { + return removeUnusedClassroomColumns(this.dataSource); } - private async removeUnusedRoomColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['rooms']); - if (tables.length === 0) return; - - const table = await runner.getTable('rooms'); - if (table?.columns.some((column) => column.name === 'gender')) { - await runner.dropColumn('rooms', 'gender'); - } - } finally { - await runner.release(); - } + async removeUnusedRoomColumns(): Promise { + return removeUnusedRoomColumns(this.dataSource); } - private async cleanupDepositRefundColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['deposits']); - if (tables.length === 0) return; - - const table = await runner.getTable('deposits'); - const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); - for (const [legacyName, currentName] of [ - ['refund_approved_by', 'refunded_by'], - ['refund_approved_at', 'refunded_at'], - ] as const) { - if (!columnNames.has(legacyName)) continue; - - if (columnNames.has(currentName)) { - await runner.query( - `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`, - ); - await runner.dropColumn('deposits', legacyName); - } else { - await runner.renameColumn('deposits', legacyName, currentName); - columnNames.add(currentName); - } - columnNames.delete(legacyName); - } - - for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) { - if (columnNames.has(columnName)) { - await runner.dropColumn('deposits', columnName); - columnNames.delete(columnName); - } - } - } finally { - await runner.release(); - } + async cleanupDepositRefundColumns(): Promise { + return cleanupDepositRefundColumns(this.dataSource); } - private async removeUnusedClassStudentColumns(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['class_student']); - if (tables.length === 0) return; - - const table = await runner.getTable('class_student'); - if (table?.columns.some((column) => column.name === 'enrollment_id')) { - await runner.dropColumn('class_student', 'enrollment_id'); - } - } finally { - await runner.release(); - } + async removeUnusedClassStudentColumns(): Promise { + return removeUnusedClassStudentColumns(this.dataSource); } - private async normalizeClassroomStatuses(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['classrooms']); - if (tables.length === 0) return; - await runner.query(` - UPDATE classrooms - SET status = 'available' - WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived') - `); - } finally { - await runner.release(); - } + async normalizeClassroomStatuses(): Promise { + return normalizeClassroomStatuses(this.dataSource); } - private async ensureAiConfigTable(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['ai_config']); - const isMySQL = this.dataSource.options.type === 'mysql'; - - if (tables.length === 0) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN'; - const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP'; - - await runner.query(` - CREATE TABLE ai_config ( - ${pkDef}, - singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL', - provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI', - base_url VARCHAR(500), - encrypted_api_key TEXT, - api_key_iv VARCHAR(50), - api_key_auth_tag VARCHAR(50), - key_last4 VARCHAR(4), - default_model VARCHAR(100), - enabled ${boolType} DEFAULT 0, - timeout_ms INT DEFAULT 30000, - verified ${boolType} DEFAULT 0, - last_tested_at DATETIME, - last_test_latency_ms INT, - created_at DATETIME NOT NULL DEFAULT ${datetimeFn}, - updated_at DATETIME NOT NULL DEFAULT ${datetimeFn} - ) - `); - - if (isMySQL) { - try { - await runner.query( - 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', - ); - } catch { - // Index may already exist; MySQL has no IF NOT EXISTS for indexes - } - } else { - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)', - ); - } - - this.logger.log('已创建 ai_config 表'); - } else { - // Check for missing columns - const table = await runner.getTable('ai_config'); - const columnNames = new Set(table?.columns.map((c) => c.name) ?? []); - - const desiredColumns: Array<{ name: string; def: string }> = [ - { name: 'id', def: '' }, // skip — primary key - { name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" }, - { name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" }, - { name: 'base_url', def: 'VARCHAR(500)' }, - { name: 'encrypted_api_key', def: 'TEXT' }, - { name: 'api_key_iv', def: 'VARCHAR(50)' }, - { name: 'api_key_auth_tag', def: 'VARCHAR(50)' }, - { name: 'key_last4', def: 'VARCHAR(4)' }, - { name: 'default_model', def: 'VARCHAR(100)' }, - { name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, - { name: 'timeout_ms', def: 'INT DEFAULT 30000' }, - { name: 'reasoning_effort', def: 'VARCHAR(20)' }, - { name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, - { name: 'last_tested_at', def: 'DATETIME' }, - { name: 'last_test_latency_ms', def: 'INT' }, - { name: 'created_at', def: 'DATETIME' }, - { name: 'updated_at', def: 'DATETIME' }, - ]; - - for (const col of desiredColumns) { - if (col.def && !columnNames.has(col.name)) { - await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`); - this.logger.log(`已为 ai_config 表添加列: ${col.name}`); - } - } - } - } finally { - await runner.release(); - } + async ensureAiConfigTable(): Promise { + return ensureAiConfigTable(this.dataSource, this.logger); } - private async ensureCourseAttendanceSchema(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables([ - 'class_schedule', - 'attendance_records', - 'attendance_sessions', - ]); - const tableNames = new Set(tables.map((table) => table.name)); - const isMySQL = this.dataSource.options.type === 'mysql'; - - if (!tableNames.has('attendance_sessions')) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - await runner.query(` - CREATE TABLE attendance_sessions ( - ${pkDef}, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - } - - if (tableNames.has('class_schedule')) { - const scheduleTable = await runner.getTable('class_schedule'); - const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []); - if (!scheduleColumns.has('attendance_advance_minutes')) { - await runner.query( - 'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30', - ); - this.logger.log('已为排课添加课前签到分钟配置'); - } - } - - const attendanceTable = await runner.getTable('attendance_records'); - const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); - if (!columnNames.has('schedule_id')) { - await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'); - } - if (!columnNames.has('attendance_session_id')) { - await runner.query( - 'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER', - ); - } - - const createIndex = async (sql: string) => { - try { - await runner.query(sql); - } catch { - // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. - } - }; - await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } finally { - await runner.release(); - } + async ensureCourseAttendanceSchema(): Promise { + return ensureCourseAttendanceSchema(this.dataSource, this.logger); } - private async backfillOrganizations(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables([ - 'tenants', - 'organizations', - 'students', - 'occupancies', - 'classroom_rentals', - ]); - const tableNames = new Set(tables.map((table) => table.name)); - if (!tableNames.has('organizations')) return; - - const organizationRows = () => - runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1'); - let host = (await organizationRows())[0]; - if (!host) { - await runner.query( - `INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, - [ - uuidV7(), - 'HOST', - process.env.HOST_ORGANIZATION_NAME || '本机构', - 1, - '#1677ff', - '系统默认运营主体', - 'active', - ], - ); - host = (await organizationRows())[0]; - } - if (!host) return; - - if (tableNames.has('tenants')) { - const legacyTenants: Array> = - await runner.query('SELECT * FROM tenants'); - for (const legacy of legacyTenants) { - const name = String(legacy.name || '').trim(); - if (!name) continue; - let external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) - )[0]; - if (!external) { - await runner.query( - `INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, - [ - uuidV7(), - `ORG_${legacy.id}`, - name, - 0, - legacy.contact || null, - legacy.phone || null, - legacy.color || null, - legacy.notes || null, - legacy.status || 'active', - ], - ); - external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) - )[0]; - } - if (!external) continue; - if (tableNames.has('students')) { - await runner - .query( - 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?', - [external.id, legacy.id], - ) - .catch(() => undefined); - } - if (tableNames.has('occupancies')) { - await runner - .query( - 'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?', - [external.id, legacy.id], - ) - .catch(() => undefined); - } - if (tableNames.has('classroom_rentals')) { - await runner - .query( - 'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?', - [external.id, host.id, legacy.id], - ) - .catch(() => undefined); - } - } - } - - if (tableNames.has('students')) { - await runner.query( - 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL', - [host.id], - ); - } - if (tableNames.has('occupancies')) { - await runner.query( - `UPDATE occupancies - SET responsible_organization_id = COALESCE( - (SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ? - ) - WHERE responsible_organization_id IS NULL`, - [host.id], - ); - } - if (tableNames.has('classroom_rentals')) { - await runner.query( - 'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL', - [host.id], - ); - } - } finally { - await runner.release(); - } + async backfillOrganizations(): Promise { + return backfillOrganizations(this.dataSource); } - private async normalizeClassDates(): Promise { - const driver = this.dataSource.options.type; - let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; - - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const table = await runner.getTable('classes'); - if (!table) return; - - // Fresh MySQL schemas created by TypeORM already use native DATE columns. - // This cleanup is only for legacy schemas that stored dates as strings; - // comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE - // in strict SQL mode. - if (driver === 'mysql') { - columns = columns.filter((columnName) => { - const column = table.columns.find((item) => item.name === columnName); - const type = String(column?.type ?? '').toLowerCase(); - return !['date', 'datetime', 'timestamp'].includes(type); - }); - if (columns.length === 0) return; - } - } finally { - await runner.release(); - } - - const columnText = (column: string) => - driver === 'mysql' ? `CAST(${column} AS CHAR)` : column; - const firstTenChars = (column: string) => - driver === 'mysql' - ? `NULLIF(LEFT(${columnText(column)}, 10), '')` - : `NULLIF(substr(${column}, 1, 10), '')`; - const normalizedDate = (column: string) => `CASE - WHEN ${column} IS NULL THEN NULL - ELSE ${firstTenChars(column)} - END`; - const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; - const needsNormalization = (column: string) => `( - ${column} IS NOT NULL - AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10) - )`; - - const assignments = columns - .map((column) => `${column} = ${normalizedDate(column)}`) - .join(',\n '); - const predicates = columns.map((column) => needsNormalization(column)).join('\n OR '); - const result = await this.dataSource.transaction((manager) => - manager.query(` - UPDATE classes - SET - ${assignments} - WHERE - ${predicates} - `), - ); - - const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows; - if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`); - } - private async protectAttendanceHistory(): Promise { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - try { - const tables = await runner.getTables(['attendance_sessions']); - if (tables.length === 0) return; - - const isMySQL = this.dataSource.options.type === 'mysql'; - if (isMySQL) { - await this.migrateMySQLAttendanceFKs(runner); - } else { - await this.migrateSQLiteAttendanceFKs(runner); - } - } finally { - await runner.release(); - } + async normalizeClassDates(): Promise { + return normalizeClassDates(this.dataSource, this.logger); } - private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise { - // Drop any existing FK constraint on schedule_id or class_id - const fkColumns = ['schedule_id', 'class_id']; - for (const col of fkColumns) { - const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( - ` - SELECT CONSTRAINT_NAME - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'attendance_sessions' - AND COLUMN_NAME = ? - AND REFERENCED_TABLE_NAME IS NOT NULL - `, - [col], - ); - - for (const row of fkRows) { - try { - await runner.query( - `ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``, - ); - this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`); - } catch { - // constraint may have already been dropped - } - } - } - - const constraints: Array<{ name: string; col: string; ref: string }> = [ - { name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' }, - { name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' }, - ]; - for (const c of constraints) { - // Only skip if RESTRICT constraint is already confirmed via information_schema - const existing: Array<{ DELETE_RULE: string }> = await runner.query( - ` - SELECT DELETE_RULE - FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS - WHERE CONSTRAINT_SCHEMA = DATABASE() - AND TABLE_NAME = 'attendance_sessions' - AND CONSTRAINT_NAME = ? - `, - [c.name], - ); - - if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { - this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`); - continue; - } - - // ADD RESTRICT must throw on failure — no catch - await runner.query(` - ALTER TABLE attendance_sessions - ADD CONSTRAINT ${c.name} - FOREIGN KEY (${c.col}) REFERENCES ${c.ref} - ON DELETE RESTRICT - `); - this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`); - } - } - - private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise { - // SQLite cannot ALTER TABLE to add foreign keys. - // Rebuild the table inside a transaction: create a new table with FK constraints, - // copy all rows, drop old, rename new, then recreate indexes. - const fkRows: Array<{ id: number }> = await runner.query( - "PRAGMA foreign_key_list('attendance_sessions')", - ); - if (fkRows.length > 0) return; // FKs already present - - this.logger.log('正在重建 attendance_sessions 表以添加外键保护…'); - - // PRAGMA foreign_keys=OFF must be issued outside the transaction - await runner.query('PRAGMA foreign_keys = OFF'); - try { - await runner.query('BEGIN'); - try { - await runner.query(` - CREATE TABLE attendance_sessions_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - await runner.query(` - INSERT INTO attendance_sessions_new ( - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - ) - SELECT - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - FROM attendance_sessions - `); - await runner.query('DROP TABLE attendance_sessions'); - await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - - // Rebuild attendance_records to add/protect FK on attendance_session_id - const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); - const hasSessionFk = recordsFk.some( - (r: { from: string }) => r.from === 'attendance_session_id', - ); - if (!hasSessionFk) { - await runner.query(` - CREATE TABLE attendance_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - schedule_id INTEGER, - attendance_session_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL - ) - `); - await runner.query(` - INSERT INTO attendance_records_new ( - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - ) - SELECT - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - FROM attendance_records - `); - await runner.query('DROP TABLE attendance_records'); - await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } - - // Verify foreign key integrity BEFORE committing the transaction. - // If violations exist, the transaction rolls back and old tables are preserved. - const checkRows = await runner.query('PRAGMA foreign_key_check'); - if (checkRows.length > 0) { - throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); - } - - await runner.query('COMMIT'); - this.logger.log('attendance_sessions 表外键保护重建完成'); - } catch (err) { - await runner.query('ROLLBACK'); - throw err; - } - } finally { - await runner.query('PRAGMA foreign_keys = ON'); - } + async protectAttendanceHistory(): Promise { + return protectAttendanceHistory(this.dataSource, this.logger); } } diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index b38d82f..55a3b5d 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -18,18 +18,20 @@ interface MockRunner { getTable: jest.Mock; } -function mockRunner(overrides: { - getTables?: MockTable[]; - getTable?: MockTable; - queryError?: Error; -} = {}) { +function mockRunner( + overrides: { + getTables?: MockTable[]; + getTable?: MockTable; + queryError?: Error; + } = {}, +) { const release = jest.fn(); const connect = jest.fn(); const query = jest.fn().mockResolvedValue([]); const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []); - const getTable = jest.fn().mockResolvedValue( - overrides.getTable ?? { name: 'ai_config', columns: [] }, - ); + const getTable = jest + .fn() + .mockResolvedValue(overrides.getTable ?? { name: 'ai_config', columns: [] }); if (overrides.queryError) { query.mockRejectedValue(overrides.queryError); @@ -68,9 +70,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); - service = module.get( - DatabaseMigrationsService, - ); + service = module.get(DatabaseMigrationsService); } it('creates table + index when ai_config does not exist', async () => { @@ -117,7 +117,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { expect(runner.connect).toHaveBeenCalled(); // Should NOT issue any ALTER TABLE const alterCalls = (runner.query as jest.Mock).mock.calls.filter( - (c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'), + (c: unknown[]) => typeof c[0] === 'string' && c[0].includes('ALTER TABLE'), ); expect(alterCalls).toHaveLength(0); expect(runner.release).toHaveBeenCalled(); @@ -206,7 +206,9 @@ describe('DatabaseMigrationsService — course attendance schema', () => { expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'), ); expect(runner.query).toHaveBeenCalledWith( - expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'), + expect.stringContaining( + 'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER', + ), ); expect(runner.release).toHaveBeenCalled(); }); @@ -222,7 +224,10 @@ describe('DatabaseMigrationsService — course attendance schema', () => { runner.getTable.mockImplementation(async (name: string) => name === 'class_schedule' ? { name, columns: [{ name: 'id' }] } - : { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] }, + : { + name, + columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }], + }, ); const service = await bootstrapCourseAttendance(runner); @@ -241,10 +246,13 @@ describe('DatabaseMigrationsService — course attendance schema', () => { const service = await bootstrapCourseAttendance(runner); await service.ensureCourseAttendanceSchema(); - const createSql: string = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')) - .find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? ''; - expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'); + const createSql: string = + (runner.query as jest.Mock).mock.calls + .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')) + .find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? ''; + expect(createSql).toContain( + 'FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT', + ); expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'); }); }); @@ -280,8 +288,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await service.protectAttendanceHistory(); // Should not run any TABLE creation (rebuild) - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0); expect(runner.release).toHaveBeenCalled(); }); @@ -297,25 +306,40 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // PRAGMA foreign_keys = OFF outside the transaction expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true); - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true); - expect(queries.some((q: string) => - q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => - q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true); + expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe( + true, + ); + expect( + queries.some((q: string) => + q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'), + ), + ).toBe(true); + expect( + queries.some((q: string) => + q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'), + ), + ).toBe(true); + expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe( + true, + ); expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true); expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true); + expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe( + true, + ); // attendance_records rebuilt with FK - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true); + expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe( + true, + ); + expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe( + true, + ); expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true); expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true); // PRAGMA foreign_keys restored to ON and foreign_key_check runs @@ -342,12 +366,11 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { }); await bootstrap(runner); - await expect(service.protectAttendanceHistory()).rejects.toThrow( - /外键一致性检查失败/, - ); + await expect(service.protectAttendanceHistory()).rejects.toThrow(/外键一致性检查失败/); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // The transaction should have been rolled back (ROLLBACK called) expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true); @@ -380,23 +403,34 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner, 'mysql'); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // Drops old FKs - expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true); - expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true); + expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe( + true, + ); + expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe( + true, + ); // Checks REFERENTIAL_CONSTRAINTS before ADD - expect(queries.some((q: string) => - q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS') - )).toBe(true); + expect( + queries.some((q: string) => q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')), + ).toBe(true); // Creates new RESTRICT FKs - expect(queries.some((q: string) => - q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => - q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT') - )).toBe(true); + expect( + queries.some( + (q: string) => + q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT'), + ), + ).toBe(true); + expect( + queries.some( + (q: string) => + q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT'), + ), + ).toBe(true); expect(runner.release).toHaveBeenCalled(); }); @@ -405,7 +439,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], }); const addError = new Error('Cannot add foreign key constraint'); - runner.query.mockImplementation((sql: string, params?: string[]) => { + runner.query.mockImplementation((sql: string, _params?: string[]) => { if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) { return Promise.resolve([]); } @@ -418,7 +452,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { return Promise.resolve([]); }); await bootstrap(runner, 'mysql'); - await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint'); + await expect(service.protectAttendanceHistory()).rejects.toThrow( + 'Cannot add foreign key constraint', + ); expect(runner.release).toHaveBeenCalled(); }); @@ -426,7 +462,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { const runner = mockRunner({ getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], }); - runner.query.mockImplementation((sql: string, params?: string[]) => { + runner.query.mockImplementation((sql: string, _params?: string[]) => { if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) { return Promise.resolve([]); } @@ -439,8 +475,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner, 'mysql'); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // No ADD CONSTRAINT calls expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0); @@ -493,10 +530,7 @@ describe('DatabaseMigrationsService — classroom cleanup', () => { async function bootstrapCourseAttendance(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ - providers: [ - DatabaseMigrationsService, - { provide: getDataSourceToken(), useValue: dataSource }, - ], + providers: [DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }], }).compile(); return module.get(DatabaseMigrationsService); } diff --git a/apps/server/src/migrations/1784780000000-AddAiChat.ts b/apps/server/src/migrations/1784780000000-AddAiChat.ts index 9b09953..2e02db9 100644 --- a/apps/server/src/migrations/1784780000000-AddAiChat.ts +++ b/apps/server/src/migrations/1784780000000-AddAiChat.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 import { MigrationInterface, QueryRunner, Table } from 'typeorm'; export class AddAiChat1784780000000 implements MigrationInterface { diff --git a/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts b/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts index ffbd994..8ad56ec 100644 --- a/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts +++ b/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 import { MigrationInterface, QueryRunner, diff --git a/apps/server/src/migrations/1784910000000-AddImportRuns.ts b/apps/server/src/migrations/1784910000000-AddImportRuns.ts new file mode 100644 index 0000000..1da6a38 --- /dev/null +++ b/apps/server/src/migrations/1784910000000-AddImportRuns.ts @@ -0,0 +1,121 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Unified staged Excel batch-import workflow (v1). + * import_runs / import_steps / import_rows back the + * upload → mapping → preview → staged commit → receipt flow. + */ +export class AddImportRuns1784910000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('import_runs')) return; + + await queryRunner.createTable( + new Table({ + name: 'import_runs', + columns: [ + { name: 'id', type: 'varchar', length: '36', isPrimary: true }, + { name: 'user_id', type: 'integer' }, + { name: 'conversation_id', type: 'integer', isNullable: true }, + { name: 'source', type: 'varchar', length: '10', default: "'manual'" }, + { name: 'file_name', type: 'varchar', length: '255' }, + { name: 'sheets_json', type: 'text' }, + { name: 'status', type: 'varchar', length: '20', default: "'preparing'" }, + { name: 'current_step_key', type: 'varchar', length: '20', isNullable: true }, + { name: 'error', type: 'varchar', length: '500', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_runs_user_created', columnNames: ['user_id', 'created_at'] }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'import_steps', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'run_id', type: 'varchar', length: '36' }, + { name: 'step_key', type: 'varchar', length: '20' }, + { name: 'sheets_json', type: 'text' }, + { name: 'mapping_json', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'summary_json', type: 'text', isNullable: true }, + { name: 'committed_at', type: 'datetime', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_steps_run_key', columnNames: ['run_id', 'step_key'] }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'import_rows', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'run_id', type: 'varchar', length: '36' }, + { name: 'step_id', type: 'integer' }, + { name: 'sheet_name', type: 'varchar', length: '200' }, + { name: 'row_number', type: 'integer' }, + { name: 'raw_json', type: 'text' }, + { name: 'normalized_json', type: 'text', isNullable: true }, + { name: 'match_key', type: 'varchar', length: '200', isNullable: true }, + { name: 'action', type: 'varchar', length: '10', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'errors_json', type: 'text', isNullable: true }, + { name: 'target_id', type: 'integer', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_rows_step', columnNames: ['step_id'] }, + { name: 'idx_import_rows_run_status', columnNames: ['run_id', 'status'] }, + ], + }), + ); + + await queryRunner.createForeignKey( + 'import_steps', + new TableForeignKey({ + name: 'fk_import_steps_run', + columnNames: ['run_id'], + referencedTableName: 'import_runs', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'import_rows', + new TableForeignKey({ + name: 'fk_import_rows_run', + columnNames: ['run_id'], + referencedTableName: 'import_runs', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('import_rows')) { + const table = await queryRunner.getTable('import_rows'); + if (table?.foreignKeys.some((fk) => fk.name === 'fk_import_rows_run')) { + await queryRunner.dropForeignKey('import_rows', 'fk_import_rows_run'); + } + await queryRunner.dropTable('import_rows'); + } + if (await queryRunner.hasTable('import_steps')) { + const table = await queryRunner.getTable('import_steps'); + if (table?.foreignKeys.some((fk) => fk.name === 'fk_import_steps_run')) { + await queryRunner.dropForeignKey('import_steps', 'fk_import_steps_run'); + } + await queryRunner.dropTable('import_steps'); + } + if (await queryRunner.hasTable('import_runs')) { + await queryRunner.dropTable('import_runs'); + } + } +} diff --git a/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts b/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts new file mode 100644 index 0000000..3633ede --- /dev/null +++ b/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 移除 ai_messages 上已废弃的 like/dislike 反馈字段。 + * 反馈功能已从前端和后端删除,历史列一并清理。 + */ +export class DropAiMessageFeedback1784920000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + for (const column of ['feedback', 'feedback_reason']) { + if (await queryRunner.hasColumn('ai_messages', column)) { + await queryRunner.dropColumn('ai_messages', column); + } + } + } + + async down(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn('ai_messages', 'feedback'))) { + await queryRunner.query( + 'ALTER TABLE ai_messages ADD COLUMN feedback varchar(20) NULL', + ); + } + if (!(await queryRunner.hasColumn('ai_messages', 'feedback_reason'))) { + await queryRunner.query( + 'ALTER TABLE ai_messages ADD COLUMN feedback_reason varchar(500) NULL', + ); + } + } +} From 644c35ce5365e69ef68b6ff36889559c7622b8b2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:51 +0800 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20RBAC=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=BD=93=E7=B3=BB=E4=B8=8E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/pages/OperationLogs/index.tsx | 56 +- apps/admin/src/pages/Permissions/index.tsx | 39 +- apps/admin/src/pages/Roles/index.tsx | 123 +-- apps/admin/src/pages/Users/index.tsx | 205 +++-- .../authorization/authorization.service.ts | 7 +- .../src/authorization/casl.constants.ts | 5 - apps/server/src/authorization/interfaces.ts | 17 +- apps/server/src/rbac/rbac-presets.ts | 268 +++++++ apps/server/src/rbac/rbac-seed.service.ts | 298 +++++++ apps/server/src/rbac/rbac-user.service.ts | 269 +++++++ apps/server/src/rbac/rbac.controller.ts | 126 +-- apps/server/src/rbac/rbac.module.ts | 8 +- apps/server/src/rbac/rbac.permissions.spec.ts | 2 +- .../src/rbac/rbac.purge.controller.spec.ts | 25 + apps/server/src/rbac/rbac.purge.spec.ts | 64 ++ apps/server/src/rbac/rbac.service.ts | 725 ++---------------- 16 files changed, 1311 insertions(+), 926 deletions(-) create mode 100644 apps/server/src/rbac/rbac-presets.ts create mode 100644 apps/server/src/rbac/rbac-seed.service.ts create mode 100644 apps/server/src/rbac/rbac-user.service.ts create mode 100644 apps/server/src/rbac/rbac.purge.controller.spec.ts create mode 100644 apps/server/src/rbac/rbac.purge.spec.ts diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index ab77076..60d83b2 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -1,8 +1,12 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { operationLogsSchema } from '../../api/schemas'; import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { getErrorMessage } from '../../utils/error'; const { RangePicker } = DatePicker; @@ -22,36 +26,38 @@ const statusMap: Record = { }; const OperationLogsPage: React.FC = () => { - const [data, setData] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [filterModule, setFilterModule] = useState(); const [dateRange, setDateRange] = useState<[string, string] | null>(null); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params: any = { page, pageSize }; - if (filterModule) params.module = filterModule; - if (dateRange) { - params.startDate = dateRange[0]; - params.endDate = dateRange[1]; + const { + data: fetchResult = { data: [], total: 0 }, + isLoading, + isFetching, + } = useQuery<{ data: any[]; total: number }>({ + queryKey: ['operation-logs', page, pageSize, filterModule, dateRange], + queryFn: async () => { + try { + const params: any = { page, pageSize }; + if (filterModule) params.module = filterModule; + if (dateRange) { + params.startDate = dateRange[0]; + params.endDate = dateRange[1]; + } + return validateResponse<{ data: any[]; total: number }>( + operationLogsSchema, + await api.get('/operation-logs', { params }), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { data: [], total: 0 }; } - const res: any = await api.get('/operation-logs', { params }); - setData(res.data); - setTotal(res.total); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [page, pageSize, filterModule, dateRange]); - - useEffect(() => { - fetchData(); - }, [fetchData]); + }, + }); + const data = fetchResult.data; + const total = fetchResult.total; + const loading = isLoading || isFetching; const columns = useMemo( () => [ diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index 53c8118..4b7b511 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -1,7 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { permissionTreeSchema } from '../../api/schemas'; import { Card, Tag, Input, Space, Spin, Empty } from 'antd'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { getErrorMessage } from '../../utils/error'; interface PermissionItem { id: number; @@ -12,10 +16,25 @@ interface PermissionItem { } const PermissionsPage: React.FC = () => { - const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [loading, setLoading] = useState(false); const [search, setSearch] = useState(''); + const { data: permTree = [], isLoading } = useQuery({ + queryKey: ['rbac', 'permissions', 'tree'], + queryFn: async () => { + try { + return validateResponse<{ group: string; permissions: PermissionItem[] }[]>( + permissionTreeSchema, + await api.get<{ group: string; permissions: PermissionItem[] }[]>( + '/rbac/permissions/tree', + ), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载权限失败')); + return []; + } + }, + }); + const groupNames: Record = { dashboard: '数据面板', student: '学生管理', @@ -44,18 +63,6 @@ const PermissionsPage: React.FC = () => { 'ai-chat': 'AI 助手', }; - useEffect(() => { - setLoading(true); - api - .get('/rbac/permissions/tree') - .then((res: any) => setPermTree(res)) - .catch((e: unknown) => { - const err = e as { message?: string }; - message.error(err?.message || '加载权限失败'); - }) - .finally(() => setLoading(false)); - }, []); - const filteredTree = search ? permTree .map((g) => ({ @@ -67,7 +74,7 @@ const PermissionsPage: React.FC = () => { .filter((g) => g.permissions.length > 0) : permTree; - if (loading) return ; + if (isLoading) return ; return (
diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index bbeae9e..e3da544 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,10 +1,15 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd'; import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { permissionTreeSchema, rolesSchema } from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; interface PermissionItem { id: number; @@ -23,36 +28,62 @@ interface RoleItem { } const RolesPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); - const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); const [form] = Form.useForm(); const [selectedPermIds, setSelectedPermIds] = useState([]); const [saving, setSaving] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [roles, permTree] = await Promise.all([ - api.get('/rbac/roles') as Promise, - api.get('/rbac/permissions/tree') as Promise< - { group: string; permissions: PermissionItem[] }[] - >, - ]); - setData(roles); - setAllPerms(permTree); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, []); + const { + data: fetchResult = { roles: [], permTree: [] }, + isLoading, + isFetching, + } = useQuery<{ + roles: RoleItem[]; + permTree: { group: string; permissions: PermissionItem[] }[]; + }>({ + queryKey: ['rbac', 'roles', 'permission-tree'], + queryFn: async () => { + try { + const [roles, permTree] = await Promise.all([ + api.get('/rbac/roles') as Promise, + api.get('/rbac/permissions/tree') as Promise< + { group: string; permissions: PermissionItem[] }[] + >, + ]); + return { + roles: validateResponse(rolesSchema, roles), + permTree: validateResponse<{ group: string; permissions: PermissionItem[] }[]>( + permissionTreeSchema, + permTree, + ), + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { roles: [], permTree: [] }; + } + }, + }); + const data = fetchResult.roles; + const allPerms = fetchResult.permTree; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); + const saveMutation = useApiMutation( + async (values: { name: string; description?: string; permissionIds: number[] }) => + editing + ? api.put(`/rbac/roles/${editing.id}`, values) + : api.post('/rbac/roles', values), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); + const disableMutation = useApiMutation( + async (id: number) => api.delete(`/rbac/roles/${id}`), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: RoleItem; field: string; value: unknown }) => + api.put(`/rbac/roles/${record.id}`, { [field]: value }), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); const handleAdd = () => { setEditing(null); @@ -72,25 +103,15 @@ const RolesPage: React.FC = () => { setSaving(true); const values = await form.validateFields(); try { - if (editing) { - await api.put(`/rbac/roles/${editing.id}`, { - name: values.name, - description: values.description, - permissionIds: selectedPermIds, - }); - message.success('角色更新成功'); - } else { - await api.post('/rbac/roles', { - name: values.name, - description: values.description, - permissionIds: selectedPermIds, - }); - message.success('角色创建成功'); - } + await saveMutation.mutateAsync({ + name: values.name, + description: values.description, + permissionIds: selectedPermIds, + }); + message.success(editing ? '角色更新成功' : '角色创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -98,11 +119,10 @@ const RolesPage: React.FC = () => { const handleDisable = async (id: number) => { try { - await api.delete(`/rbac/roles/${id}`); + await disableMutation.mutateAsync(id); message.success('角色已停用'); - fetchData(); - } catch (e: any) { - message.error(e.message || '停用失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -144,11 +164,14 @@ const RolesPage: React.FC = () => { ); const saveCell = useCallback( async (record: RoleItem, field: string, value: unknown) => { - await api.put(`/rbac/roles/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns = useMemo( diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index edcf68d..e1ef1c2 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd'; +import React, { useState, useMemo, useCallback } from 'react'; +import { App, Button, Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd'; import { PlusOutlined, EditOutlined, @@ -13,11 +13,25 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form'; +import { usePermission } from '../../hooks/usePermission'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { rolesSchema, usersSchema } from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; + +const USER_FIELDS = { + username: 'username', + name: 'name', + phone: 'phone', + email: 'email', + status: 'status', +} as const; const UsersPage: React.FC = () => { - const [data, setData] = useState([]); - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeUser = hasPermission('user:purge'); const [modalOpen, setModalOpen] = useState(false); const [pwdModalOpen, setPwdModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -45,36 +59,73 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await profileForm.validateFields(); try { - await api.put(`/rbac/users/${profileUser.id}/profile`, values); + await profileMutation.mutateAsync({ id: profileUser.id, values }); message.success('档案更新成功'); setProfileModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [users, rolesRes] = await Promise.all([ - api.get(`/rbac/users?isArchived=${showArchived}`) as Promise, - api.get('/rbac/roles') as Promise, - ]); - setData(users); - setRoles(rolesRes); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [showArchived]); + const { + data: fetchResult = { users: [], roles: [] }, + isLoading, + isFetching, + } = useQuery<{ users: any[]; roles: any[] }>({ + queryKey: ['rbac', 'users', showArchived], + queryFn: async () => { + try { + const [users, rolesRes] = await Promise.all([ + api.get(`/rbac/users?isArchived=${showArchived}`) as Promise, + api.get('/rbac/roles') as Promise, + ]); + return { + users: validateResponse(usersSchema, users), + roles: validateResponse(rolesSchema, rolesRes), + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { users: [], roles: [] }; + } + }, + }); + const data = fetchResult.users; + const roles = fetchResult.roles; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); + const profileMutation = useApiMutation( + async ({ id, values }: { id: number; values: any }) => + api.put(`/rbac/users/${id}/profile`, values), + { invalidate: [['rbac', 'users']] }, + ); + const saveMutation = useApiMutation( + async (values: { username: string; password?: string; name: string; roleIds: number[] }) => + editing + ? api.put(`/rbac/users/${editing.id}`, values) + : api.post('/rbac/users', values), + { invalidate: [['rbac', 'users']] }, + ); + const archiveMutation = useApiMutation( + async ({ id, archive }: { id: number; archive: boolean }) => + api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`), + { invalidate: [['rbac', 'users']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/rbac/users/${id}/permanent`), + { invalidate: [['rbac', 'users']] }, + ); + const pwdMutation = useApiMutation( + async ({ id, password }: { id: number; password: string }) => + api.put(`/rbac/users/${id}/password`, { password }), + { invalidate: [['rbac', 'users']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/rbac/users/${record.id}`, { [field]: value }), + { invalidate: [['rbac', 'users']] }, + ); const handleAdd = () => { setEditing(null); @@ -96,41 +147,48 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await form.validateFields(); try { - if (editing) { - await api.put(`/rbac/users/${editing.id}`, { - username: values.username, - name: values.name, - roleIds: values.roleIds || [], - }); - message.success('更新成功'); - } else { - await api.post('/rbac/users', { - username: values.username, - password: values.password, - name: values.name, - roleIds: values.roleIds || [], - }); - message.success('创建成功'); - } + await saveMutation.mutateAsync({ + username: values.username, + password: values.password, + name: values.name, + roleIds: values.roleIds || [], + }); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handleArchive = async (id: number, archive: boolean) => { try { - await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`); + await archiveMutation.mutateAsync({ id, archive }); message.success(archive ? '已归档' : '已恢复'); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (record: any) => { + modal.confirm({ + title: `永久删除账号「${record.name || record.username}」?`, + content: + '删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleResetPwd = (record: any) => { setResetTarget(record); pwdForm.resetFields(); @@ -141,12 +199,11 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await pwdForm.validateFields(); try { - await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password }); + await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password }); message.success('密码已重置'); setPwdModalOpen(false); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -154,11 +211,14 @@ const UsersPage: React.FC = () => { const saveCell = useCallback( async (record: any, field: string, value: unknown) => { - await api.put(`/rbac/users/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns = useMemo( @@ -166,7 +226,7 @@ const UsersPage: React.FC = () => { { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', - dataIndex: 'username', + dataIndex: USER_FIELDS.username, width: 120, render: (v: string, r: any) => ( { required permission="user:edit" disabled={r.isArchived} - onSave={(next) => saveCell(r, 'username', next)} + onSave={(next) => saveCell(r, USER_FIELDS.username, next)} > {v} @@ -182,7 +242,7 @@ const UsersPage: React.FC = () => { }, { title: '姓名', - dataIndex: 'name', + dataIndex: USER_FIELDS.name, width: 120, render: (v: string, r: any) => ( { required permission="user:edit" disabled={r.isArchived} - onSave={(next) => saveCell(r, 'name', next)} + onSave={(next) => saveCell(r, USER_FIELDS.name, next)} > {v} @@ -267,11 +327,18 @@ const UsersPage: React.FC = () => { 重置密码 {record.isArchived ? ( - handleArchive(record.id, false)}> - - 恢复 - - + <> + handleArchive(record.id, false)}> + + 恢复 + + + {canPurgeUser ? ( + + ) : null} + ) : ( { ), }, ], - [roles, saveCell], + [roles, saveCell, canPurgeUser, handlePurge], ); return ( diff --git a/apps/server/src/authorization/authorization.service.ts b/apps/server/src/authorization/authorization.service.ts index b512bde..d6182a0 100644 --- a/apps/server/src/authorization/authorization.service.ts +++ b/apps/server/src/authorization/authorization.service.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { ForbiddenException } from '@nestjs/common'; +import { Injectable, ForbiddenException } from '@nestjs/common'; import { CaslAbilityFactory } from './casl-ability.factory'; import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces'; import { CaslAction, permissionCodeSubject } from './casl.constants'; @@ -69,9 +68,7 @@ export class AuthorizationService { */ assertPermission(ability: AppAbility, permissionCode: string): void { if (!this.canPermission(ability, permissionCode)) { - throw new ForbiddenException( - `权限不足:缺少权限码 ${permissionCode}`, - ); + throw new ForbiddenException(`权限不足:缺少权限码 ${permissionCode}`); } } diff --git a/apps/server/src/authorization/casl.constants.ts b/apps/server/src/authorization/casl.constants.ts index e928bf5..e35718a 100644 --- a/apps/server/src/authorization/casl.constants.ts +++ b/apps/server/src/authorization/casl.constants.ts @@ -66,11 +66,6 @@ export function permissionCodeSubject(code: string): string { return `PermissionCode:${code}`; } -// --------------------------------------------------------------------------- -// Domain-level action mapping: permission code → CASL action -// Used ONLY for the domain layer — not for exact-code access checks. -// --------------------------------------------------------------------------- - function permissionToAction(permission: string): CaslAction | null { const actionSegment = permission.split(':')[1] ?? permission; diff --git a/apps/server/src/authorization/interfaces.ts b/apps/server/src/authorization/interfaces.ts index 15660f1..e978a5f 100644 --- a/apps/server/src/authorization/interfaces.ts +++ b/apps/server/src/authorization/interfaces.ts @@ -1,10 +1,6 @@ import { MongoAbility } from '@casl/ability'; import { CaslAction } from './casl.constants'; -// --------------------------------------------------------------------------- -// Subject type union — all entity classes we protect with CASL. -// --------------------------------------------------------------------------- - // CASL expects the subject to be either the class constructor or a string. // We use string subjects (SubjectName) for simplicity when no instance is // available, and concrete instance types for per-resource checks. @@ -12,10 +8,6 @@ export type AppSubject = string | Record; export type AppAbility = MongoAbility<[CaslAction, AppSubject]>; -// --------------------------------------------------------------------------- -// Authenticated user — what the JWT strategy places on `request.user`. -// --------------------------------------------------------------------------- - export interface AuthenticatedUser { id: number; username: string; @@ -31,17 +23,16 @@ export interface AuthenticatedUser { * Minimum authorization principal — the subset of AuthenticatedUser * needed by CaslAbilityFactory and AuthorizationService. */ -export type AuthPrincipal = { readonly permissions: readonly string[]; readonly isSuperAdmin: boolean }; +export type AuthPrincipal = { + readonly permissions: readonly string[]; + readonly isSuperAdmin: boolean; +}; /** Request-like carrier populated only by the trusted authentication layer. */ export interface AuthorizationRequest { user?: AuthPrincipal; } -// --------------------------------------------------------------------------- -// Policy handler types for @CheckPolicies() -// --------------------------------------------------------------------------- - /** * Interface for class-based policy handlers. * diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts new file mode 100644 index 0000000..e8deccc --- /dev/null +++ b/apps/server/src/rbac/rbac-presets.ts @@ -0,0 +1,268 @@ +export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ + { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, + { code: 'notification:view', name: '查看通知', group: 'notification' }, + { code: 'student:view', name: '查看学生管理', group: 'student' }, + { code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, + { code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' }, + { code: 'teacher:view', name: '查看教师', group: 'teacher' }, + { code: 'teacher:edit', name: '编辑教师', group: 'teacher' }, + { code: 'student:create', name: '新增学生', group: 'student' }, + { code: 'student:edit', name: '编辑学生', group: 'student' }, + { code: 'student:delete', name: '归档学生', group: 'student' }, + { code: 'student:import', name: '导入学生', group: 'student' }, + { code: 'student:export', name: '导出学生', group: 'student' }, + { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, + { code: 'room:view', name: '查看宿舍', group: 'room' }, + { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, + { code: 'room:create', name: '新增宿舍', group: 'room' }, + { code: 'room:edit', name: '编辑宿舍', group: 'room' }, + { code: 'room:delete', name: '归档宿舍', group: 'room' }, + { code: 'occupancy:view', name: '查看入住', group: 'occupancy' }, + { code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' }, + { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, + { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, + { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, + { code: 'expense:view', name: '查看费用', group: 'expense' }, + { code: 'expense:create', name: '录入费用', group: 'expense' }, + { code: 'expense:edit', name: '编辑费用', group: 'expense' }, + { code: 'expense:delete', name: '归档费用', group: 'expense' }, + { code: 'bill:view', name: '查看账单', group: 'bill' }, + { code: 'bill:generate', name: '生成账单', group: 'bill' }, + { code: 'bill:confirm', name: '确认账单', group: 'bill' }, + { code: 'bill:delete', name: '归档账单', group: 'bill' }, + { code: 'bill:export-excel', name: '导出 Excel', group: 'bill' }, + { code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' }, + { code: 'deposit:view', name: '查看押金', group: 'deposit' }, + { code: 'deposit:create', name: '新增押金', group: 'deposit' }, + { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, + { code: 'deposit:delete', name: '归档押金', group: 'deposit' }, + { code: 'deposit:refund', name: '直接退还押金', group: 'deposit' }, + { code: 'wallet:view', name: '查看学生余额', group: 'wallet' }, + { code: 'wallet:edit', name: '充值和调账', group: 'wallet' }, + { code: 'classroom:view', name: '查看教室', group: 'classroom' }, + { code: 'classroom:create', name: '新增教室', group: 'classroom' }, + { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, + { code: 'classroom:delete', name: '归档教室', group: 'classroom' }, + { code: 'organization:view', name: '查看机构', group: 'organization' }, + { code: 'organization:create', name: '新增机构', group: 'organization' }, + { code: 'organization:edit', name: '编辑机构', group: 'organization' }, + { code: 'organization:delete', name: '归档机构', group: 'organization' }, + { code: 'rental:view', name: '查看租赁订单', group: 'rental' }, + { code: 'rental:create', name: '新增租赁订单', group: 'rental' }, + { code: 'rental:edit', name: '编辑租赁订单', group: 'rental' }, + { code: 'rental:delete', name: '归档租赁订单', group: 'rental' }, + { code: 'log:view', name: '查看操作日志', group: 'log' }, + { code: 'log:create', name: '写入操作日志', group: 'log' }, + { code: 'user:view', name: '查看用户', group: 'user' }, + { code: 'user:create', name: '创建用户', group: 'user' }, + { code: 'user:edit', name: '编辑用户', group: 'user' }, + { code: 'user:reset-password', name: '重置密码', group: 'user' }, + { code: 'role:view', name: '查看角色', group: 'role' }, + { code: 'role:create', name: '创建角色', group: 'role' }, + { code: 'role:edit', name: '编辑角色', group: 'role' }, + { code: 'role:delete', name: '停用角色', group: 'role' }, + { code: 'class:view', name: '查看班级', group: 'class' }, + { code: 'class:create', name: '创建班级', group: 'class' }, + { code: 'class:edit', name: '编辑班级', group: 'class' }, + { code: 'class:delete', name: '归档班级', group: 'class' }, + { code: 'schedule:view', name: '查看排课', group: 'schedule' }, + { code: 'schedule:create', name: '创建排课', group: 'schedule' }, + { code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, + { code: 'schedule:delete', name: '停用排课', group: 'schedule' }, + { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, + { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, + { code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' }, + { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, + { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, + { code: 'sync:trigger', name: '触发数据同步', group: 'sync' }, + { code: 'sync:read', name: '查看同步状态', group: 'sync' }, + { code: 'integration:trigger', name: '触发集成', group: 'integration' }, + { code: 'integration:read', name: '查看集成状态', group: 'integration' }, + // 永久删除(两步删除:先归档/取消,再在已归档视图物理删除) + { code: 'student:purge', name: '永久删除学生', group: 'purge' }, + { code: 'room:purge', name: '永久删除宿舍', group: 'purge' }, + { code: 'classroom:purge', name: '永久删除教室', group: 'purge' }, + { code: 'occupancy:purge', name: '永久删除入住记录', group: 'purge' }, + { code: 'expense:purge', name: '永久删除费用', group: 'purge' }, + { code: 'exam:purge', name: '永久删除考试', group: 'purge' }, + { code: 'bill:purge', name: '永久删除账单', group: 'purge' }, + { code: 'deposit:purge', name: '永久删除押金', group: 'purge' }, + { code: 'organization:purge', name: '永久删除机构', group: 'purge' }, + { code: 'rental:purge', name: '永久删除租赁订单', group: 'purge' }, + { code: 'class:purge', name: '永久删除班级', group: 'purge' }, + { code: 'user:purge', name: '永久删除用户', group: 'purge' }, + { code: 'archive:purge', name: '永久删除档案记录', group: 'purge' }, + { code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' }, + { code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' }, + { code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' }, + { code: 'ai:chat:use', name: '使用 AI 助手', group: 'ai-chat' }, +]; + +export const DEPRECATED_PERMISSION_CODES = [ + 'profile:view', + 'attendance:generate', + 'learning:create', + 'learning:edit', + 'learning:delete', + 'exam:create', + 'exam:edit', + 'exam:delete', + 'department:view', + 'department:edit', + 'department:delete', + // Legacy permission codes from older admin UI / seed data. + 'student:add', + 'student:update', + 'room:add', + 'room:update', + 'occupancy:add', + 'occupancy:update', + 'attendance:add', + 'attendance:update', + 'attendance:delete', + 'attendance:batch', + 'bill:export', + 'deposit:collect', + 'expense:add', + 'expense:update', + 'class:add', + 'class:update', + 'schedule:add', + 'schedule:update', + 'classroom:add', + 'classroom:update', + 'rental:add', + 'rental:update', + 'role:add', + 'role:update', + 'user:add', + 'user:update', + 'archive:view', + 'archive:import', + 'archive:export', + 'report:generate', +] as const; + +export const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); + +export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + weekday: 'short', + }) + .formatToParts(date) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]), + ); + const weekDays: Record = { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7, + }; + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + weekDay: weekDays[parts.weekday], + }; +} + +export const PRESET_ROLES: Array<{ + name: string; + code: string; + description: string; + isSystem: boolean; + permissionGroups: string[]; + extraPermissions?: string[]; + legacyNames?: string[]; + legacyCodes?: string[]; +}> = [ + { + name: '超级管理员', + code: 'super_admin', + description: '系统初始化、应急维护和全局权限处理', + isSystem: true, + permissionGroups: [], + legacyNames: ['超管', 'super_admin'], + }, + { + name: '任课老师', + code: 'teacher', + description: '查看自己的排课、今日课程和任教班级考勤', + isSystem: true, + permissionGroups: ['notification'], + extraPermissions: [ + 'teacher-workspace:view', + 'schedule:view', + 'attendance:view', + 'attendance:create', + 'attendance:self-edit', + ], + legacyNames: ['老师'], + }, + { + name: '教务管理员', + code: 'academic', + description: '管理学生、班级、教师、全局排课和历史考勤', + isSystem: true, + permissionGroups: [ + 'student', + 'exam', + 'class', + 'schedule', + 'attendance', + 'classroom', + 'dashboard', + 'notification', + ], + extraPermissions: [ + 'teacher-workspace:view', + 'teacher:view', + 'teacher:edit', + 'sync:read', + 'sync:trigger', + ], + legacyNames: ['教务'], + }, + { + name: '住宿运营管理员', + code: 'accommodation_operations', + description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算', + isSystem: true, + permissionGroups: [ + 'room', + 'occupancy', + 'expense', + 'bill', + 'deposit', + 'wallet', + 'dashboard', + 'notification', + ], + extraPermissions: ['student:basic-view'], + legacyNames: ['宿管老师', '宿管', '财务'], + legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'], + }, + { + name: '教室运营管理员', + code: 'classroom_operations', + description: '管理教室、教室排期、外部机构和租赁订单', + isSystem: true, + permissionGroups: ['classroom', 'rental', 'organization', 'notification'], + legacyNames: ['机构负责人'], + legacyCodes: ['institution_head'], + }, + { + name: '系统管理员', + code: 'system_admin', + description: '管理账号、角色、日志、同步和系统配置', + isSystem: true, + permissionGroups: ['user', 'role', 'log', 'integration', 'sync', 'ai', 'notification'], + }, +]; diff --git a/apps/server/src/rbac/rbac-seed.service.ts b/apps/server/src/rbac/rbac-seed.service.ts new file mode 100644 index 0000000..402081e --- /dev/null +++ b/apps/server/src/rbac/rbac-seed.service.ts @@ -0,0 +1,298 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession } from '../entities'; +import { + PRESET_ROLES, + PRESET_PERMISSIONS, + DEPRECATED_PERMISSION_CODES, + DEPRECATED_PERMISSION_CODE_SET, + getChinaDateParts, +} from './rbac-presets'; + +@Injectable() +export class RbacService { + private readonly logger = new Logger(RbacService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, + ) {} + + async findAllRoles(): Promise { + return this.roleRepo.find({ + relations: ['permissions'], + order: { id: 'ASC' }, + }); + } + + async findRoleById(id: number): Promise { + return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); + } + + private async resolvePermissions(permissionIds: number[]): Promise { + const uniqueIds = [...new Set(permissionIds)]; + const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; + if (permissions.length !== uniqueIds.length) { + const foundIds = new Set(permissions.map((permission) => permission.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`权限不存在: ${missingIds.join(',')}`); + } + return permissions; + } + + async getTeacherWorkspace(userId: number) { + // Find all classes where this user is a teacher + const teacherAssignments = await this.classTeacherRepo.find({ + where: { userId }, + relations: ['class'], + }); + + const classIds = [...new Set(teacherAssignments.map((t) => t.classId))]; + + if (classIds.length === 0) { + return { assignedClasses: [], todaySchedules: [], myStudents: [] }; + } + + const assignedClasses = teacherAssignments.map((t) => ({ + classId: t.classId, + className: t.class?.name || '', + classCode: t.class?.code || '', + roleType: t.roleType, + subject: t.subject, + })); + + // Get today's China business date and day of week (1=Monday, 7=Sunday) + const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts(); + + const todaySchedules = await this.classScheduleRepo + .createQueryBuilder('cs') + .where('cs.classId IN (:...classIds)', { classIds }) + .andWhere('cs.weekDay = :weekDay', { weekDay: adjustedWeekDay }) + .andWhere('cs.startDate <= :today', { today: todayStr }) + .andWhere('cs.endDate >= :today', { today: todayStr }) + .andWhere('cs.status = :status', { status: 'active' }) + .orderBy('cs.startTime', 'ASC') + .getMany(); + + const classStudents = await this.classStudentRepo.find({ + where: { classId: In(classIds), status: 'active' }, + relations: ['student', 'class'], + }); + + const myStudents = classStudents.map((cs) => ({ + studentId: cs.studentId, + studentName: cs.student?.name || '', + studentNo: cs.student?.studentNo || '', + className: cs.class?.name || '', + classId: cs.classId, + joinDate: cs.joinDate, + })); + + return { + assignedClasses, + todaySchedules: todaySchedules.map((s) => ({ + id: s.id, + classId: s.classId, + classroomId: s.classroomId, + teacherId: s.teacherId, + weekDay: s.weekDay, + startTime: s.startTime, + endTime: s.endTime, + subject: s.subject, + scheduleType: s.scheduleType, + })), + myStudents, + }; + } +} + +@Injectable() +export class RbacSeedService { + private readonly logger = new Logger(RbacSeedService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + ) {} + + private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise { + for (const code of preset.legacyCodes ?? []) { + const role = await this.roleRepo.findOne({ where: { code } }); + if (role) return role; + } + for (const name of preset.legacyNames ?? []) { + const role = await this.roleRepo.findOne({ where: { name } }); + if (role) return role; + } + return null; + } + + async seedData(): Promise { + const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); + if (restoredLegacyUsers.affected) { + this.logger.log( + `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + ); + } + + for (const p of PRESET_PERMISSIONS) { + const exists = await this.permRepo.findOne({ where: { code: p.code } }); + if (!exists) { + await this.permRepo.save(this.permRepo.create(p)); + } + } + const deprecatedUserDeletePermission = await this.permRepo.findOne({ + where: { code: 'user:delete' }, + }); + const allPerms = (await this.permRepo.find()).filter( + (permission) => + permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code), + ); + + for (const r of PRESET_ROLES) { + const exists = + (await this.roleRepo.findOne({ where: { code: r.code } })) || + (await this.roleRepo.findOne({ where: { name: r.name } })) || + (await this.findLegacyPresetRole(r)); + if (!exists) { + await this.roleRepo.save( + this.roleRepo.create({ + name: r.name, + code: r.code, + description: r.description, + isSystem: r.isSystem, + }), + ); + } + } + const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] }); + + if (deprecatedUserDeletePermission) { + for (const role of allRoles) { + const permissions = role.permissions ?? []; + if (permissions.some((permission) => permission.id === deprecatedUserDeletePermission.id)) { + role.permissions = permissions.filter( + (permission) => permission.id !== deprecatedUserDeletePermission.id, + ); + await this.roleRepo.save(role); + } + } + await this.permRepo.remove(deprecatedUserDeletePermission); + } + + const deprecatedPermissions = await this.permRepo.find({ + where: { code: In([...DEPRECATED_PERMISSION_CODES]) }, + }); + if (deprecatedPermissions.length > 0) { + const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id)); + for (const role of allRoles) { + const permissions = role.permissions ?? []; + if (permissions.some((permission) => deprecatedIds.has(permission.id))) { + role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id)); + await this.roleRepo.save(role); + } + } + await this.permRepo.remove(deprecatedPermissions); + this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`); + } + + for (const preset of PRESET_ROLES) { + const matchesPreset = (role: Role) => + role.name === preset.name || + role.code === preset.code || + preset.legacyNames?.includes(role.name) || + preset.legacyCodes?.includes(role.code); + const candidates = allRoles.filter(matchesPreset); + const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0]; + if (!role) continue; + + const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id); + if (duplicateRoles.length > 0) { + for (const duplicate of duplicateRoles) { + for (const relatedUser of duplicate.users ?? []) { + const user = await this.userRepo.findOne({ + where: { id: relatedUser.id }, + relations: ['roles'], + }); + if (!user) continue; + const remainingRoles = (user.roles ?? []).filter( + (assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id, + ); + user.roles = [...remainingRoles, role]; + await this.userRepo.save(user); + } + await this.roleRepo.remove(duplicate); + } + } + + if ( + role.code !== preset.code || + role.name !== preset.name || + role.description !== preset.description + ) { + role.code = preset.code; + role.name = preset.name; + role.description = preset.description; + role.isSystem = preset.isSystem; + role.status = 1; + await this.roleRepo.save(role); + } + + let perms: Permission[]; + if (preset.permissionGroups.length === 0) { + // 超管:全部权限 + perms = allPerms; + } else { + // 按 group 匹配 + 额外权限(如老师的 student:view) + const byGroup = allPerms.filter((p) => preset.permissionGroups.includes(p.group)); + const byExtra = preset.extraPermissions + ? allPerms.filter((p) => preset.extraPermissions!.includes(p.code)) + : []; + perms = [...byGroup, ...byExtra].filter( + (p, i, arr) => arr.findIndex((x) => x.id === p.id) === i, + ); + } + + // 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。 + const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b); + const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b); + if (currentIds.join(',') !== targetIds.join(',')) { + role.permissions = perms; + await this.roleRepo.save(role); + } + } + + const count = await this.userRepo.count(); + if (count === 0) { + const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; + const hash = await bcrypt.hash(adminPassword, 10); + const adminUser = this.userRepo.create({ + username: 'admin', + passwordHash: hash, + name: '管理员', + }); + const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); + if (superAdminRole) { + adminUser.roles = [superAdminRole]; + } + await this.userRepo.save(adminUser); + this.logger.log( + `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, + ); + } + + this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); + } + +} diff --git a/apps/server/src/rbac/rbac-user.service.ts b/apps/server/src/rbac/rbac-user.service.ts new file mode 100644 index 0000000..5831775 --- /dev/null +++ b/apps/server/src/rbac/rbac-user.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities'; +import { Role } from '../entities/role.entity'; + +@Injectable() +export class RbacUserService { + private readonly logger = new Logger(RbacUserService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, + ) {} + + async resolvePermissions(permissionIds: number[]): Promise { + const uniqueIds = [...new Set(permissionIds)]; + const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; + if (permissions.length !== uniqueIds.length) { + const foundIds = new Set(permissions.map((permission) => permission.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`权限不存在: ${missingIds.join(',')}`); + } + return permissions; + } + + private async resolveRoles(roleIds: number[]): Promise { + const uniqueIds = [...new Set(roleIds)]; + const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : []; + if (roles.length !== uniqueIds.length) { + const foundIds = new Set(roles.map((role) => role.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`角色不存在: ${missingIds.join(',')}`); + } + return roles; + } + + async findAllUsers(isArchived = false) { + const users = await this.userRepo.find({ + where: { isArchived }, + relations: ['roles'], + order: { createdAt: 'DESC' }, + }); + const userIds = users.map((u) => u.id); + const students = await this.studentRepo.find({ + where: { userId: In(userIds) }, + select: ['userId', 'status'], + }); + const statusMap = new Map(students.map((s) => [s.userId, s.status])); + return users.map((u) => ({ + id: u.id, + username: u.username, + name: u.name, + isArchived: u.isArchived, + studentStatus: statusMap.get(u.id) || null, + lastLoginAt: u.lastLoginAt, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + roles: u.roles?.map((r) => ({ id: r.id, code: r.code, name: r.name })) || [], + profile: u.profile || {}, + })); + } + + async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { + const exists = await this.userRepo.findOne({ where: { username: dto.username } }); + if (exists) throw new Error('用户名已存在'); + const hash = await bcrypt.hash(dto.password, 10); + const user = this.userRepo.create({ + username: dto.username, + passwordHash: hash, + name: dto.name, + }); + if (dto.roleIds && dto.roleIds.length > 0) { + user.roles = await this.resolveRoles(dto.roleIds); + } + await this.userRepo.save(user); + return { message: '用户创建成功' }; + } + + async updateUser(id: number, dto: { username?: string; name?: string; roleIds?: number[] }) { + const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] }); + if (!user) throw new Error('用户不存在'); + if (dto.username !== undefined && dto.username !== user.username) { + const exists = await this.userRepo.findOne({ where: { username: dto.username } }); + if (exists) throw new Error('用户名已存在'); + user.username = dto.username; + } + if (dto.name !== undefined) user.name = dto.name; + if (dto.roleIds !== undefined) { + user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; + } + await this.userRepo.save(user); + return { message: '更新成功' }; + } + + async resetPassword(id: number, newPassword: string) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + user.passwordHash = await bcrypt.hash(newPassword, 10); + await this.userRepo.save(user); + return { message: '密码已重置' }; + } + + async archiveUser(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + if (user.username === 'admin') throw new Error('不能归档默认管理员'); + await this.userRepo.update(id, { isArchived: true }); + return { message: '用户已归档' }; + } + + async restoreUser(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + await this.userRepo.update(id, { isArchived: false, isActive: true }); + return { message: '用户已恢复' }; + } + + async purgeUser(id: number, currentUserId: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + if (!user.isArchived) throw new Error('仅已归档用户可以永久删除,请先归档'); + if (user.id === currentUserId) throw new Error('不能永久删除当前登录用户'); + if (user.username === 'admin') throw new Error('不能永久删除默认管理员'); + + const [studentCount, classTeacherCount, scheduleCount, sessionStarted, sessionCompleted] = + await Promise.all([ + this.studentRepo.count({ where: { userId: id } }), + this.classTeacherRepo.count({ where: { userId: id } }), + this.classScheduleRepo.count({ where: { teacherId: id } }), + this.attendanceSessionRepo.count({ where: { startedBy: id } }), + this.attendanceSessionRepo.count({ where: { completedBy: id } }), + ]); + const classHeadCount = await this.classRepo.count({ + where: [{ headTeacherId: id }, { lifeTeacherId: id }, { academicTeacherId: id }], + }); + const references: string[] = []; + if (studentCount > 0) references.push('关联学生'); + if (classTeacherCount > 0) references.push('任教班级'); + if (scheduleCount > 0) references.push('排课'); + if (classHeadCount > 0) references.push('班主任班级'); + if (sessionStarted > 0 || sessionCompleted > 0) references.push('考勤课次操作记录'); + if (references.length > 0) { + throw new Error(`该用户存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.userRepo.delete(id); + return { message: '用户已永久删除(不可恢复)' }; + } + + async markAsStaff(userId: number) { + const student = await this.studentRepo.findOne({ where: { userId } }); + if (!student) throw new Error('该用户没有学员记录'); + await this.studentRepo.update(student.id, { status: 'staff' }); + this.logger.log(`User ${userId} Student ${student.id} marked as staff`); + return { message: '已标记为教职工' }; + } + + async markAsStudent(userId: number) { + const student = await this.studentRepo.findOne({ where: { userId } }); + if (!student) throw new Error('该用户没有学员记录'); + await this.studentRepo.update(student.id, { status: 'active' }); + this.logger.log(`User ${userId} Student ${student.id} restored to student`); + return { message: '已恢复为学员' }; + } + + async getUserProfile(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + return { + id: user.id, + username: user.username, + name: user.name, + profile: user.profile || {}, + }; + } + + async updateUserProfile( + id: number, + dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }, + ) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + const current = user.profile || {}; + user.profile = { + subjects: dto.subjects !== undefined ? dto.subjects : current.subjects, + joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt, + qualifications: + dto.qualifications !== undefined ? dto.qualifications : current.qualifications, + }; + await this.userRepo.save(user); + return { message: '资料已更新', profile: user.profile }; + } + + + async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { + const page = query?.page || 1; + const pageSize = query?.pageSize || 20; + const teacherRoleCodes = ['teacher']; + const teacherRoleNames = ['任课老师', '老师']; + + const qb = this.userRepo + .createQueryBuilder('u') + .leftJoinAndSelect('u.roles', 'role') + .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', { + roleCodes: teacherRoleCodes, + roleNames: teacherRoleNames, + }) + .andWhere('u.isArchived = :isArchived', { isArchived: false }); + + if (query?.search) { + qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); + } + + const total = await qb.getCount(); + const users = await qb + .orderBy('u.name', 'ASC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getMany(); + + const userIds = users.map((user) => user.id); + const assignments = + userIds.length > 0 + ? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] }) + : []; + const assignmentsByUser = new Map(); + for (const assignment of assignments) { + const list = assignmentsByUser.get(assignment.userId) || []; + list.push(assignment); + assignmentsByUser.set(assignment.userId, list); + } + + const list = users.map((u) => ({ + id: u.id, + username: u.username, + name: u.name, + profile: u.profile, + lastLoginAt: u.lastLoginAt, + roles: u.roles || [], + classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({ + id: assignment.id, + classId: assignment.classId, + roleType: assignment.roleType, + subject: assignment.subject, + className: assignment.class?.name || null, + })), + })); + + return { list, total }; + } + + async updateTeacherProfile( + id: number, + profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }, + ) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new NotFoundException('用户不存在'); + user.profile = { ...user.profile, ...profile }; + return this.userRepo.save(user); + } +} diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index a933b63..c4dee8f 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -23,7 +23,7 @@ import { import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; @UseGuards(JwtAuthGuard) @Controller('rbac') @@ -33,8 +33,6 @@ export class RbacController { private logService: OperationLogsService, ) {} - // ==================== 角色管理 ==================== - @Get('roles') @RequirePermission('role:view') findAllRoles() { @@ -50,16 +48,9 @@ export class RbacController { @Post('roles') @RequirePermission('role:create') async createRole(@Body() dto: CreateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.rbacService.createRole(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '创建角色', - detail: `角色: ${dto.name}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, }); return result; } @@ -67,19 +58,10 @@ export class RbacController { @Put('roles/:id') @RequirePermission('role:edit') async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateRole(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '编辑角色', - targetId: +id, - targetType: 'role', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), }); return result; } catch (e: any) { @@ -90,18 +72,10 @@ export class RbacController { @Delete('roles/:id') @RequirePermission('role:delete') async deleteRole(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.deleteRole(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '停用角色', - targetId: +id, - targetType: 'role', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role', }); return result; } catch (e: any) { @@ -109,8 +83,6 @@ export class RbacController { } } - // ==================== 权限管理 ==================== - @Get('permissions') @RequirePermission('role:view') findAllPermissions() { @@ -123,8 +95,6 @@ export class RbacController { return this.rbacService.getPermissionTree(); } - // ==================== 用户管理 ==================== - @Get('users') @RequirePermission('user:view', 'teacher:view') getUsers(@Query('isArchived') isArchived?: string) { @@ -135,17 +105,10 @@ export class RbacController { @Post('users') @RequirePermission('user:create') async createUser(@Body() dto: CreateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.createUser(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '创建账号', - detail: `用户名: ${dto.username}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, }); return result; } catch (e: any) { @@ -156,19 +119,10 @@ export class RbacController { @Put('users/:id') @RequirePermission('user:edit') async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateUser(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '更新账号', - targetId: +id, - targetType: 'user', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), }); return result; } catch (e: any) { @@ -179,18 +133,10 @@ export class RbacController { @Put('users/:id/password') @RequirePermission('user:reset-password') async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.resetPassword(+id, dto.password); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '重置密码', - targetId: +id, - targetType: 'user', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '重置密码', targetId: +id, targetType: 'user', }); return result; } catch (e: any) { @@ -220,6 +166,21 @@ export class RbacController { } } + @Delete('users/:id/permanent') + @RequirePermission('user:purge') + async purgeUser(@Param('id') id: string, @Request() req: any) { + try { + const result = await this.rbacService.purgeUser(+id, req.user?.id); + await logAudit(this.logService, req, { + module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复', + }); + return result; + } catch (e: unknown) { + const err = e as { message?: string }; + throw new BadRequestException(err?.message); + } + } + @Put('users/:id/mark-staff') @RequirePermission('user:edit') async markAsStaff(@Param('id') id: string) { @@ -242,8 +203,6 @@ export class RbacController { } } - // ---- 用户资料 ---- - @Get('users/:id/profile') @RequirePermission('user:view') getUserProfile(@Param('id') id: string) { @@ -257,18 +216,10 @@ export class RbacController { @Body() dto: UpdateProfileDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateUserProfile(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '更新资料', - targetId: +id, - targetType: 'user', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '更新资料', targetId: +id, targetType: 'user', }); return result; } catch (e: any) { @@ -276,16 +227,12 @@ export class RbacController { } } - // ---- 教师工作台 ---- - @Get('teacher-workspace') @RequirePermission('teacher-workspace:view') async getTeacherWorkspace(@Request() req: any) { return this.rbacService.getTeacherWorkspace(req.user?.id); } - // ---- 教师管理 ---- - @Get('teachers') @RequirePermission('teacher:view') async getTeachers( @@ -307,18 +254,9 @@ export class RbacController { @Body() profile: UpdateProfileDto, @Request() req: { user?: { id: number; username: string } }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.rbacService.updateTeacherProfile(+id, profile); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教师管理', - action: '编辑档案', - targetId: +id, - targetType: 'user', - detail: '更新教师档案', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教师管理', action: '编辑档案', targetId: +id, targetType: 'user', detail: '更新教师档案', }); return result; } diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/rbac/rbac.module.ts index 4db07c0..f5bd07b 100644 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/rbac/rbac.module.ts @@ -1,14 +1,16 @@ import { Module, OnModuleInit, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping } from '../entities'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping, AttendanceSession } from '../entities'; import { RbacService } from './rbac.service'; +import { RbacSeedService } from './rbac-seed.service'; +import { RbacUserService } from './rbac-user.service'; import { RbacController } from './rbac.controller'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]), forwardRef(() => AuthModule)], + imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping, AttendanceSession]), forwardRef(() => AuthModule)], controllers: [RbacController], - providers: [RbacService], + providers: [RbacService, RbacSeedService, RbacUserService], exports: [RbacService], }) export class RbacModule implements OnModuleInit { diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts index 5cbf2d5..6ee39e9 100644 --- a/apps/server/src/rbac/rbac.permissions.spec.ts +++ b/apps/server/src/rbac/rbac.permissions.spec.ts @@ -1,4 +1,4 @@ -import { PRESET_ROLES } from './rbac.service'; +import { PRESET_ROLES } from './rbac-presets'; function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } { const role = PRESET_ROLES.find((item) => item.code === roleCode); diff --git a/apps/server/src/rbac/rbac.purge.controller.spec.ts b/apps/server/src/rbac/rbac.purge.controller.spec.ts new file mode 100644 index 0000000..fad0724 --- /dev/null +++ b/apps/server/src/rbac/rbac.purge.controller.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { RbacController } from './rbac.controller'; + +describe('RbacController purge user route', () => { + it('requires user:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, RbacController.prototype.purgeUser)).toEqual([ + 'user:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const rbacService = { + purgeUser: jest.fn().mockResolvedValue({ message: '用户已永久删除(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new RbacController(rbacService as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeUser('2', req); + expect(rbacService.purgeUser).toHaveBeenCalledWith(2, 1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '账号', action: '永久删除用户', targetId: 2 }), + ); + }); +}); diff --git a/apps/server/src/rbac/rbac.purge.spec.ts b/apps/server/src/rbac/rbac.purge.spec.ts new file mode 100644 index 0000000..cacda71 --- /dev/null +++ b/apps/server/src/rbac/rbac.purge.spec.ts @@ -0,0 +1,64 @@ +import { RbacService } from './rbac.service'; + +describe('RbacService.purgeUser', () => { + const createService = (overrides?: { + user?: Record; + counts?: Record; + }) => { + const user = { + id: 2, + username: 'teacher1', + isArchived: true, + ...overrides?.user, + }; + const userRepo = { + findOne: jest.fn().mockResolvedValue(user), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const service = new RbacService( + {} as never, + {} as never, + userRepo as never, + { count: countFor('headTeacher') } as never, + {} as never, + { count: countFor('classTeacher') } as never, + { count: countFor('schedule') } as never, + { count: countFor('student') } as never, + { count: countFor('session') } as never, + ); + return { service, userRepo }; + }; + + it('rejects the current user, the admin user, and non-archived users', async () => { + const current = createService(); + await expect(current.service.purgeUser(2, 2)).rejects.toThrow( + '不能永久删除当前登录用户', + ); + + const admin = createService({ user: { username: 'admin' } }); + await expect(admin.service.purgeUser(2, 1)).rejects.toThrow('不能永久删除默认管理员'); + + const active = createService({ user: { isArchived: false } }); + await expect(active.service.purgeUser(2, 1)).rejects.toThrow( + '仅已归档用户可以永久删除,请先归档', + ); + }); + + it('rejects users with student, class, schedule, or attendance references', async () => { + const { service, userRepo } = createService({ counts: { student: 1 } }); + await expect(service.purgeUser(2, 1)).rejects.toThrow( + '该用户存在关联数据(关联学生),无法永久删除', + ); + expect(userRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived user with no references', async () => { + const { service, userRepo } = createService(); + await expect(service.purgeUser(2, 1)).resolves.toEqual({ + message: '用户已永久删除(不可恢复)', + }); + expect(userRepo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 638f301..84a689e 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -1,7 +1,10 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; +import type { UpdateProfileDto } from './dto/rbac.dto'; +import { RbacSeedService } from './rbac-seed.service'; +import { RbacUserService } from './rbac-user.service'; +import { getChinaDateParts } from './rbac-presets'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; -import * as bcrypt from 'bcryptjs'; import { Permission, Role, @@ -11,263 +14,9 @@ import { ClassTeacher, ClassSchedule, Student, + AttendanceSession, } from '../entities'; -const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ - { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, - { code: 'notification:view', name: '查看通知', group: 'notification' }, - { code: 'student:view', name: '查看学生管理', group: 'student' }, - { code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, - { code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' }, - { code: 'teacher:view', name: '查看教师', group: 'teacher' }, - { code: 'teacher:edit', name: '编辑教师', group: 'teacher' }, - { code: 'student:create', name: '新增学生', group: 'student' }, - { code: 'student:edit', name: '编辑学生', group: 'student' }, - { code: 'student:delete', name: '归档学生', group: 'student' }, - { code: 'student:import', name: '导入学生', group: 'student' }, - { code: 'student:export', name: '导出学生', group: 'student' }, - { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, - { code: 'room:view', name: '查看宿舍', group: 'room' }, - { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, - { code: 'room:create', name: '新增宿舍', group: 'room' }, - { code: 'room:edit', name: '编辑宿舍', group: 'room' }, - { code: 'room:delete', name: '归档宿舍', group: 'room' }, - { code: 'occupancy:view', name: '查看入住', group: 'occupancy' }, - { code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' }, - { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, - { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, - { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, - { code: 'expense:view', name: '查看费用', group: 'expense' }, - { code: 'expense:create', name: '录入费用', group: 'expense' }, - { code: 'expense:edit', name: '编辑费用', group: 'expense' }, - { code: 'expense:delete', name: '归档费用', group: 'expense' }, - { code: 'bill:view', name: '查看账单', group: 'bill' }, - { code: 'bill:generate', name: '生成账单', group: 'bill' }, - { code: 'bill:confirm', name: '确认账单', group: 'bill' }, - { code: 'bill:delete', name: '归档账单', group: 'bill' }, - { code: 'bill:export-excel', name: '导出 Excel', group: 'bill' }, - { code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' }, - { code: 'deposit:view', name: '查看押金', group: 'deposit' }, - { code: 'deposit:create', name: '新增押金', group: 'deposit' }, - { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, - { code: 'deposit:delete', name: '归档押金', group: 'deposit' }, - { code: 'deposit:refund', name: '直接退还押金', group: 'deposit' }, - { code: 'wallet:view', name: '查看学生余额', group: 'wallet' }, - { code: 'wallet:edit', name: '充值和调账', group: 'wallet' }, - { code: 'classroom:view', name: '查看教室', group: 'classroom' }, - { code: 'classroom:create', name: '新增教室', group: 'classroom' }, - { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, - { code: 'classroom:delete', name: '归档教室', group: 'classroom' }, - { code: 'organization:view', name: '查看机构', group: 'organization' }, - { code: 'organization:create', name: '新增机构', group: 'organization' }, - { code: 'organization:edit', name: '编辑机构', group: 'organization' }, - { code: 'organization:delete', name: '归档机构', group: 'organization' }, - { code: 'rental:view', name: '查看租赁订单', group: 'rental' }, - { code: 'rental:create', name: '新增租赁订单', group: 'rental' }, - { code: 'rental:edit', name: '编辑租赁订单', group: 'rental' }, - { code: 'rental:delete', name: '归档租赁订单', group: 'rental' }, - { code: 'log:view', name: '查看操作日志', group: 'log' }, - { code: 'log:create', name: '写入操作日志', group: 'log' }, - { code: 'user:view', name: '查看用户', group: 'user' }, - { code: 'user:create', name: '创建用户', group: 'user' }, - { code: 'user:edit', name: '编辑用户', group: 'user' }, - { code: 'user:reset-password', name: '重置密码', group: 'user' }, - { code: 'role:view', name: '查看角色', group: 'role' }, - { code: 'role:create', name: '创建角色', group: 'role' }, - { code: 'role:edit', name: '编辑角色', group: 'role' }, - { code: 'role:delete', name: '停用角色', group: 'role' }, - { code: 'class:view', name: '查看班级', group: 'class' }, - { code: 'class:create', name: '创建班级', group: 'class' }, - { code: 'class:edit', name: '编辑班级', group: 'class' }, - { code: 'class:delete', name: '归档班级', group: 'class' }, - { code: 'schedule:view', name: '查看排课', group: 'schedule' }, - { code: 'schedule:create', name: '创建排课', group: 'schedule' }, - { code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, - { code: 'schedule:delete', name: '停用排课', group: 'schedule' }, - { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, - { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, - { code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' }, - { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, - { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, - { code: 'sync:trigger', name: '触发数据同步', group: 'sync' }, - { code: 'sync:read', name: '查看同步状态', group: 'sync' }, - { code: 'integration:trigger', name: '触发集成', group: 'integration' }, - { code: 'integration:read', name: '查看集成状态', group: 'integration' }, - { code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' }, - { code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' }, - { code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' }, - { code: 'ai:chat:use', name: '使用 AI 助手', group: 'ai-chat' }, -]; - -const DEPRECATED_PERMISSION_CODES = [ - 'profile:view', - 'attendance:generate', - 'learning:create', - 'learning:edit', - 'learning:delete', - 'exam:create', - 'exam:edit', - 'exam:delete', - 'department:view', - 'department:edit', - 'department:delete', - // Legacy permission codes from older admin UI / seed data. - 'student:add', - 'student:update', - 'room:add', - 'room:update', - 'occupancy:add', - 'occupancy:update', - 'attendance:add', - 'attendance:update', - 'attendance:delete', - 'attendance:batch', - 'bill:export', - 'deposit:collect', - 'expense:add', - 'expense:update', - 'class:add', - 'class:update', - 'schedule:add', - 'schedule:update', - 'classroom:add', - 'classroom:update', - 'rental:add', - 'rental:update', - 'role:add', - 'role:update', - 'user:add', - 'user:update', - 'archive:view', - 'archive:import', - 'archive:export', - 'report:generate', -] as const; - -const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); - -function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - weekday: 'short', - }) - .formatToParts(date) - .filter((part) => part.type !== 'literal') - .map((part) => [part.type, part.value]), - ); - const weekDays: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; - return { - date: `${parts.year}-${parts.month}-${parts.day}`, - weekDay: weekDays[parts.weekday], - }; -} - -export const PRESET_ROLES: Array<{ - name: string; - code: string; - description: string; - isSystem: boolean; - permissionGroups: string[]; - extraPermissions?: string[]; - legacyNames?: string[]; - legacyCodes?: string[]; -}> = [ - { - name: '超级管理员', - code: 'super_admin', - description: '系统初始化、应急维护和全局权限处理', - isSystem: true, - permissionGroups: [], - legacyNames: ['超管', 'super_admin'], - }, - { - name: '任课老师', - code: 'teacher', - description: '查看自己的排课、今日课程和任教班级考勤', - isSystem: true, - permissionGroups: ['notification'], - extraPermissions: [ - 'teacher-workspace:view', - 'schedule:view', - 'attendance:view', - 'attendance:create', - 'attendance:self-edit', - ], - legacyNames: ['老师'], - }, - { - name: '教务管理员', - code: 'academic', - description: '管理学生、班级、教师、全局排课和历史考勤', - isSystem: true, - permissionGroups: [ - 'student', - 'exam', - 'class', - 'schedule', - 'attendance', - 'classroom', - 'dashboard', - 'notification', - ], - extraPermissions: [ - 'teacher-workspace:view', - 'teacher:view', - 'teacher:edit', - 'sync:read', - 'sync:trigger', - ], - legacyNames: ['教务'], - }, - { - name: '住宿运营管理员', - code: 'accommodation_operations', - description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算', - isSystem: true, - permissionGroups: [ - 'room', - 'occupancy', - 'expense', - 'bill', - 'deposit', - 'wallet', - 'dashboard', - 'notification', - ], - extraPermissions: ['student:basic-view'], - legacyNames: ['宿管老师', '宿管', '财务'], - legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'], - }, - { - name: '教室运营管理员', - code: 'classroom_operations', - description: '管理教室、教室排期、外部机构和租赁订单', - isSystem: true, - permissionGroups: ['classroom', 'rental', 'organization', 'notification'], - legacyNames: ['机构负责人'], - legacyCodes: ['institution_head'], - }, - { - name: '系统管理员', - code: 'system_admin', - description: '管理账号、角色、日志、同步和系统配置', - isSystem: true, - permissionGroups: [ - 'user', - 'role', - 'log', - 'integration', - 'sync', - 'ai', - 'notification', - ], - }, -]; - @Injectable() export class RbacService { private readonly logger = new Logger(RbacService.name); @@ -281,179 +30,34 @@ export class RbacService { @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, + @Optional() private seedService?: RbacSeedService, + @Optional() private userService?: RbacUserService, ) {} - private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise { - for (const code of preset.legacyCodes ?? []) { - const role = await this.roleRepo.findOne({ where: { code } }); - if (role) return role; + private get seedOps(): RbacSeedService { + if (!this.seedService) { + this.seedService = new RbacSeedService(this.permRepo, this.roleRepo, this.userRepo); } - for (const name of preset.legacyNames ?? []) { - const role = await this.roleRepo.findOne({ where: { name } }); - if (role) return role; - } - return null; + return this.seedService; } - async seedData(): Promise { - const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); - if (restoredLegacyUsers.affected) { - this.logger.log( - `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + private get userOps(): RbacUserService { + if (!this.userService) { + this.userService = new RbacUserService( + this.permRepo, + this.roleRepo, + this.userRepo, + this.classRepo, + this.classStudentRepo, + this.classTeacherRepo, + this.classScheduleRepo, + this.studentRepo, + this.attendanceSessionRepo, ); } - - // Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL) - for (const p of PRESET_PERMISSIONS) { - const exists = await this.permRepo.findOne({ where: { code: p.code } }); - if (!exists) { - await this.permRepo.save(this.permRepo.create(p)); - } - } - const deprecatedUserDeletePermission = await this.permRepo.findOne({ - where: { code: 'user:delete' }, - }); - const allPerms = (await this.permRepo.find()).filter( - (permission) => - permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code), - ); - - // Step 2: 幂等插入预置角色 - for (const r of PRESET_ROLES) { - const exists = - (await this.roleRepo.findOne({ where: { code: r.code } })) || - (await this.roleRepo.findOne({ where: { name: r.name } })) || - (await this.findLegacyPresetRole(r)); - if (!exists) { - await this.roleRepo.save( - this.roleRepo.create({ - name: r.name, - code: r.code, - description: r.description, - isSystem: r.isSystem, - }), - ); - } - } - const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] }); - - if (deprecatedUserDeletePermission) { - for (const role of allRoles) { - const permissions = role.permissions ?? []; - if (permissions.some((permission) => permission.id === deprecatedUserDeletePermission.id)) { - role.permissions = permissions.filter( - (permission) => permission.id !== deprecatedUserDeletePermission.id, - ); - await this.roleRepo.save(role); - } - } - await this.permRepo.remove(deprecatedUserDeletePermission); - } - - const deprecatedPermissions = await this.permRepo.find({ - where: { code: In([...DEPRECATED_PERMISSION_CODES]) }, - }); - if (deprecatedPermissions.length > 0) { - const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id)); - for (const role of allRoles) { - const permissions = role.permissions ?? []; - if (permissions.some((permission) => deprecatedIds.has(permission.id))) { - role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id)); - await this.roleRepo.save(role); - } - } - await this.permRepo.remove(deprecatedPermissions); - this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`); - } - - // Step 3: 合并旧角色并构建新的职责权限矩阵 - for (const preset of PRESET_ROLES) { - const matchesPreset = (role: Role) => - role.name === preset.name || - role.code === preset.code || - preset.legacyNames?.includes(role.name) || - preset.legacyCodes?.includes(role.code); - const candidates = allRoles.filter(matchesPreset); - const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0]; - if (!role) continue; - - const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id); - if (duplicateRoles.length > 0) { - for (const duplicate of duplicateRoles) { - for (const relatedUser of duplicate.users ?? []) { - const user = await this.userRepo.findOne({ - where: { id: relatedUser.id }, - relations: ['roles'], - }); - if (!user) continue; - const remainingRoles = (user.roles ?? []).filter( - (assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id, - ); - user.roles = [...remainingRoles, role]; - await this.userRepo.save(user); - } - await this.roleRepo.remove(duplicate); - } - } - - if ( - role.code !== preset.code || - role.name !== preset.name || - role.description !== preset.description - ) { - role.code = preset.code; - role.name = preset.name; - role.description = preset.description; - role.isSystem = preset.isSystem; - role.status = 1; - await this.roleRepo.save(role); - } - - let perms: Permission[]; - if (preset.permissionGroups.length === 0) { - // 超管:全部权限 - perms = allPerms; - } else { - // 按 group 匹配 + 额外权限(如老师的 student:view) - const byGroup = allPerms.filter((p) => preset.permissionGroups.includes(p.group)); - const byExtra = preset.extraPermissions - ? allPerms.filter((p) => preset.extraPermissions!.includes(p.code)) - : []; - perms = [...byGroup, ...byExtra].filter( - (p, i, arr) => arr.findIndex((x) => x.id === p.id) === i, - ); - } - - // 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。 - const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b); - const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b); - if (currentIds.join(',') !== targetIds.join(',')) { - role.permissions = perms; - await this.roleRepo.save(role); - } - } - - // Step 4: 初始化 admin 用户 - const count = await this.userRepo.count(); - if (count === 0) { - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = await bcrypt.hash(adminPassword, 10); - const adminUser = this.userRepo.create({ - username: 'admin', - passwordHash: hash, - name: '管理员', - }); - const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); - if (superAdminRole) { - adminUser.roles = [superAdminRole]; - } - await this.userRepo.save(adminUser); - this.logger.log( - `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, - ); - } - - this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); + return this.userService; } async findAllRoles(): Promise { @@ -467,28 +71,6 @@ export class RbacService { return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); } - private async resolvePermissions(permissionIds: number[]): Promise { - const uniqueIds = [...new Set(permissionIds)]; - const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; - if (permissions.length !== uniqueIds.length) { - const foundIds = new Set(permissions.map((permission) => permission.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`权限不存在: ${missingIds.join(',')}`); - } - return permissions; - } - - private async resolveRoles(roleIds: number[]): Promise { - const uniqueIds = [...new Set(roleIds)]; - const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : []; - if (roles.length !== uniqueIds.length) { - const foundIds = new Set(roles.map((role) => role.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`角色不存在: ${missingIds.join(',')}`); - } - return roles; - } - async createRole(dto: { name: string; description?: string; @@ -496,7 +78,7 @@ export class RbacService { }): Promise { const role = this.roleRepo.create({ name: dto.name, description: dto.description }); if (dto.permissionIds && dto.permissionIds.length > 0) { - role.permissions = await this.resolvePermissions(dto.permissionIds); + role.permissions = await this.userOps.resolvePermissions(dto.permissionIds); } return this.roleRepo.save(role); } @@ -513,7 +95,7 @@ export class RbacService { if (dto.description !== undefined) role.description = dto.description; if (dto.permissionIds !== undefined) { role.permissions = - dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : []; + dto.permissionIds.length > 0 ? await this.userOps.resolvePermissions(dto.permissionIds) : []; } return this.roleRepo.save(role); } @@ -557,139 +139,6 @@ export class RbacService { return Array.from(codes); } - // ---- 用户管理 ---- - - async findAllUsers(isArchived = false) { - const users = await this.userRepo.find({ - where: { isArchived }, - relations: ['roles'], - order: { createdAt: 'DESC' }, - }); - const userIds = users.map((u) => u.id); - const students = await this.studentRepo.find({ - where: { userId: In(userIds) }, - select: ['userId', 'status'], - }); - const statusMap = new Map(students.map((s) => [s.userId, s.status])); - return users.map((u) => ({ - id: u.id, - username: u.username, - name: u.name, - isArchived: u.isArchived, - studentStatus: statusMap.get(u.id) || null, - lastLoginAt: u.lastLoginAt, - createdAt: u.createdAt, - updatedAt: u.updatedAt, - roles: u.roles?.map((r) => ({ id: r.id, code: r.code, name: r.name })) || [], - profile: u.profile || {}, - })); - } - - async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - const hash = await bcrypt.hash(dto.password, 10); - const user = this.userRepo.create({ - username: dto.username, - passwordHash: hash, - name: dto.name, - }); - if (dto.roleIds && dto.roleIds.length > 0) { - user.roles = await this.resolveRoles(dto.roleIds); - } - await this.userRepo.save(user); - return { message: '用户创建成功' }; - } - - async updateUser( - id: number, - dto: { username?: string; name?: string; roleIds?: number[] }, - ) { - const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] }); - if (!user) throw new Error('用户不存在'); - if (dto.username !== undefined && dto.username !== user.username) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - user.username = dto.username; - } - if (dto.name !== undefined) user.name = dto.name; - if (dto.roleIds !== undefined) { - user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; - } - await this.userRepo.save(user); - return { message: '更新成功' }; - } - - async resetPassword(id: number, newPassword: string) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - user.passwordHash = await bcrypt.hash(newPassword, 10); - await this.userRepo.save(user); - return { message: '密码已重置' }; - } - - async archiveUser(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - if (user.username === 'admin') throw new Error('不能归档默认管理员'); - await this.userRepo.update(id, { isArchived: true }); - return { message: '用户已归档' }; - } - - async restoreUser(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - await this.userRepo.update(id, { isArchived: false, isActive: true }); - return { message: '用户已恢复' }; - } - - async markAsStaff(userId: number) { - const student = await this.studentRepo.findOne({ where: { userId } }); - if (!student) throw new Error('该用户没有学员记录'); - await this.studentRepo.update(student.id, { status: 'staff' }); - this.logger.log(`User ${userId} Student ${student.id} marked as staff`); - return { message: '已标记为教职工' }; - } - - async markAsStudent(userId: number) { - const student = await this.studentRepo.findOne({ where: { userId } }); - if (!student) throw new Error('该用户没有学员记录'); - await this.studentRepo.update(student.id, { status: 'active' }); - this.logger.log(`User ${userId} Student ${student.id} restored to student`); - return { message: '已恢复为学员' }; - } - - // ---- 用户资料 ---- - - async getUserProfile(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - return { - id: user.id, - username: user.username, - name: user.name, - profile: user.profile || {}, - }; - } - - async updateUserProfile( - id: number, - dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }, - ) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - const current = user.profile || {}; - user.profile = { - subjects: dto.subjects !== undefined ? dto.subjects : current.subjects, - joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt, - qualifications: - dto.qualifications !== undefined ? dto.qualifications : current.qualifications, - }; - await this.userRepo.save(user); - return { message: '资料已更新', profile: user.profile }; - } - - // ---- 教师工作台 ---- async getTeacherWorkspace(userId: number) { // Find all classes where this user is a teacher @@ -704,7 +153,6 @@ export class RbacService { return { assignedClasses: [], todaySchedules: [], myStudents: [] }; } - // Get assigned classes const assignedClasses = teacherAssignments.map((t) => ({ classId: t.classId, className: t.class?.name || '', @@ -716,7 +164,6 @@ export class RbacService { // Get today's China business date and day of week (1=Monday, 7=Sunday) const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts(); - // Get today's schedules for assigned classes const todaySchedules = await this.classScheduleRepo .createQueryBuilder('cs') .where('cs.classId IN (:...classIds)', { classIds }) @@ -727,7 +174,6 @@ export class RbacService { .orderBy('cs.startTime', 'ASC') .getMany(); - // Get students in assigned classes const classStudents = await this.classStudentRepo.find({ where: { classId: In(classIds), status: 'active' }, relations: ['student', 'class'], @@ -758,71 +204,60 @@ export class RbacService { myStudents, }; } + async seedData(): Promise { + return this.seedOps.seedData(); + } + + async findAllUsers(isArchived = false) { + return this.userOps.findAllUsers(isArchived); + } + + async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { + return this.userOps.createUser(dto); + } + + async updateUser(id: number, dto: { username?: string; name?: string; roleIds?: number[] }) { + return this.userOps.updateUser(id, dto); + } + + async resetPassword(id: number, newPassword: string) { + return this.userOps.resetPassword(id, newPassword); + } + + async archiveUser(id: number) { + return this.userOps.archiveUser(id); + } + + async restoreUser(id: number) { + return this.userOps.restoreUser(id); + } + + async purgeUser(id: number, currentUserId: number) { + return this.userOps.purgeUser(id, currentUserId); + } + + async markAsStaff(userId: number) { + return this.userOps.markAsStaff(userId); + } + + async markAsStudent(userId: number) { + return this.userOps.markAsStudent(userId); + } + + async getUserProfile(id: number) { + return this.userOps.getUserProfile(id); + } + + async updateUserProfile(id: number, dto: UpdateProfileDto) { + return this.userOps.updateUserProfile(id, dto); + } async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { - const page = query?.page || 1; - const pageSize = query?.pageSize || 20; - const teacherRoleCodes = ['teacher']; - const teacherRoleNames = ['任课老师', '老师']; - - const qb = this.userRepo - .createQueryBuilder('u') - .leftJoinAndSelect('u.roles', 'role') - .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', { - roleCodes: teacherRoleCodes, - roleNames: teacherRoleNames, - }) - .andWhere('u.isArchived = :isArchived', { isArchived: false }); - - if (query?.search) { - qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); - } - - const total = await qb.getCount(); - const users = await qb - .orderBy('u.name', 'ASC') - .skip((page - 1) * pageSize) - .take(pageSize) - .getMany(); - - const userIds = users.map((user) => user.id); - const assignments = - userIds.length > 0 - ? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] }) - : []; - const assignmentsByUser = new Map(); - for (const assignment of assignments) { - const list = assignmentsByUser.get(assignment.userId) || []; - list.push(assignment); - assignmentsByUser.set(assignment.userId, list); - } - - const list = users.map((u) => ({ - id: u.id, - username: u.username, - name: u.name, - profile: u.profile, - lastLoginAt: u.lastLoginAt, - roles: u.roles || [], - classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({ - id: assignment.id, - classId: assignment.classId, - roleType: assignment.roleType, - subject: assignment.subject, - className: assignment.class?.name || null, - })), - })); - - return { list, total }; + return this.userOps.getTeachers(query); } - async updateTeacherProfile( - id: number, - profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }, - ) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new NotFoundException('用户不存在'); - user.profile = { ...user.profile, ...profile }; - return this.userRepo.save(user); + async updateTeacherProfile(id: number, dto: UpdateProfileDto) { + return this.userOps.updateTeacherProfile(id, dto); } + } From 0e6e3e2d96a3d0f6394ac614e1a4f3699773a107 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:11:00 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20AI=20=E5=AF=B9=E8=AF=9D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20A2UI=20=E8=A1=A8=E5=8D=95/=E5=AE=A1=E6=9F=A5/?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E4=B8=8E=20Agent=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiChat/AiChatDrawer.helpers.tsx | 122 + .../components/AiChat/AiChatDrawer.parts.tsx | 232 ++ .../src/components/AiChat/AiChatDrawer.tsx | 759 ++--- .../components/AiChat/AiMessageContent.tsx | 194 +- .../src/components/AiChat/DynamicChart.tsx | 252 +- .../src/components/AiChat/DynamicForm.tsx | 49 +- .../src/components/AiChat/DynamicReview.tsx | 283 +- .../components/AiChat/LiteCodeHighlighter.tsx | 49 + .../src/components/AiChat/LiteMermaid.tsx | 48 + apps/admin/src/components/AiChat/api.ts | 25 +- .../message-mappers.integration.test.ts | 2 - .../src/components/AiChat/message-mappers.ts | 2 - .../AiChat/provider.integration.test.ts | 4 +- apps/admin/src/components/AiChat/provider.ts | 115 +- .../src/components/AiChat/reviewSection.ts | 115 + apps/admin/src/components/AiChat/style.css | 62 + apps/admin/src/components/AiChat/types.ts | 23 +- .../AiChat/useAiChatMessageActions.tsx | 574 ++++ .../src/pages/AiConfig/AiConfigSteps.tsx | 415 +++ .../AiConfig/helpers.integration.test.ts | 24 +- apps/admin/src/pages/AiConfig/helpers.ts | 37 +- apps/admin/src/pages/AiConfig/index.tsx | 614 +---- .../src/agent-tools/agent-skill.catalog.ts | 2 - .../src/agent-tools/agent-tool.registry.ts | 11 - .../src/agent-tools/agent-tool.types.ts | 28 +- .../tools/get-dashboard-stats.tool.ts | 4 +- .../agent-tools/tools/get-sync-status.tool.ts | 3 +- .../tools/search-students.tool.spec.ts | 1 - .../src/ai-chat/ai-attachment.service.ts | 10 +- .../ai-chat-enhancement.migration.spec.ts | 25 + apps/server/src/ai-chat/ai-chat.constants.ts | 226 ++ apps/server/src/ai-chat/ai-chat.controller.ts | 48 +- .../src/ai-chat/ai-chat.conversations.ts | 272 ++ apps/server/src/ai-chat/ai-chat.generation.ts | 242 ++ apps/server/src/ai-chat/ai-chat.helpers.ts | 48 + apps/server/src/ai-chat/ai-chat.module.ts | 2 + .../src/ai-chat/ai-chat.service.spec.ts | 629 +++-- apps/server/src/ai-chat/ai-chat.service.ts | 2438 +++-------------- apps/server/src/ai-chat/ai-chat.streaming.ts | 374 +++ .../server/src/ai-chat/ai-chat.submissions.ts | 406 +++ .../src/ai-chat/ai-chat.tool-actions.ts | 318 +++ .../server/src/ai-chat/ai-chat.tool-office.ts | 129 + apps/server/src/ai-chat/ai-chat.tools.ts | 197 ++ apps/server/src/ai-chat/ai-chat.types.ts | 170 ++ .../src/ai-chat/ai-excel-reader.service.ts | 2 +- .../src/ai-chat/ai-form.service.spec.ts | 79 +- apps/server/src/ai-chat/ai-review.enrich.ts | 277 ++ .../src/ai-chat/ai-review.import-basic.ts | 181 ++ .../src/ai-chat/ai-review.import-relations.ts | 299 ++ .../src/ai-chat/ai-review.service.spec.ts | 272 +- apps/server/src/ai-chat/ai-review.service.ts | 1714 +----------- apps/server/src/ai-chat/ai-review.shared.ts | 328 +++ apps/server/src/ai-chat/ai-review.submit.ts | 351 +++ .../src/ai-chat/ai-review.validation.ts | 165 ++ apps/server/src/ai-chat/ai-review.workbook.ts | 250 ++ apps/server/src/ai-chat/dto/ai-chat.dto.ts | 24 +- .../src/ai-chat/entities/ai-message.entity.ts | 8 +- .../src/ai-chat/entities/ai-review.entity.ts | 13 +- apps/server/src/ai-chat/office-cli.service.ts | 7 - .../src/ai-config/ai-config.controller.ts | 40 +- .../server/src/ai-config/ai-config.helpers.ts | 351 +++ apps/server/src/ai-config/ai-config.probe.ts | 260 ++ .../server/src/ai-config/ai-config.service.ts | 623 +---- .../server/src/ai-config/dto/ai-config.dto.ts | 2 + 64 files changed, 8395 insertions(+), 6434 deletions(-) create mode 100644 apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx create mode 100644 apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx create mode 100644 apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx create mode 100644 apps/admin/src/components/AiChat/LiteMermaid.tsx create mode 100644 apps/admin/src/components/AiChat/reviewSection.ts create mode 100644 apps/admin/src/components/AiChat/useAiChatMessageActions.tsx create mode 100644 apps/admin/src/pages/AiConfig/AiConfigSteps.tsx create mode 100644 apps/server/src/ai-chat/ai-chat.constants.ts create mode 100644 apps/server/src/ai-chat/ai-chat.conversations.ts create mode 100644 apps/server/src/ai-chat/ai-chat.generation.ts create mode 100644 apps/server/src/ai-chat/ai-chat.helpers.ts create mode 100644 apps/server/src/ai-chat/ai-chat.streaming.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submissions.ts create mode 100644 apps/server/src/ai-chat/ai-chat.tool-actions.ts create mode 100644 apps/server/src/ai-chat/ai-chat.tool-office.ts create mode 100644 apps/server/src/ai-chat/ai-chat.tools.ts create mode 100644 apps/server/src/ai-chat/ai-review.enrich.ts create mode 100644 apps/server/src/ai-chat/ai-review.import-basic.ts create mode 100644 apps/server/src/ai-chat/ai-review.import-relations.ts create mode 100644 apps/server/src/ai-chat/ai-review.shared.ts create mode 100644 apps/server/src/ai-chat/ai-review.submit.ts create mode 100644 apps/server/src/ai-chat/ai-review.validation.ts create mode 100644 apps/server/src/ai-chat/ai-review.workbook.ts create mode 100644 apps/server/src/ai-config/ai-config.helpers.ts create mode 100644 apps/server/src/ai-config/ai-config.probe.ts diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx new file mode 100644 index 0000000..3bec7d5 --- /dev/null +++ b/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { BubbleListProps } from '@ant-design/x'; +import type { Attachment } from '@ant-design/x/es/attachments'; +import type { MessageInfo } from '@ant-design/x-sdk'; +import { Tooltip } from 'antd'; +import type { AiAttachment, AiChatMessage, AiConversation } from './types'; + +export interface ConversationData extends AiConversation { + key: string; + label: string; +} + +export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped'; + +export function conversationStatusMeta(status: ConversationRunStatus): { + label: string; + color: string; +} { + if (status === 'running') return { label: '生成中', color: 'processing' }; + if (status === 'done') return { label: '已完成', color: 'success' }; + if (status === 'error') return { label: '失败', color: 'error' }; + return { label: '已停止', color: 'default' }; +} + +export function sortConversations(items: AiConversation[]): AiConversation[] { + return [...items].sort((a, b) => { + const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime(); + const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime(); + return bTime - aTime; + }); +} + +export function toConversationData(item: AiConversation): ConversationData { + return { ...item, key: String(item.id), label: item.title }; +} + +export function toUploadFile(attachment: AiAttachment): Attachment { + return { + uid: String(attachment.id), + name: attachment.name, + size: attachment.size, + status: + attachment.status === 'ready' + ? 'done' + : attachment.status === 'failed' + ? 'error' + : 'uploading', + url: attachment.url, + response: attachment, + description: attachment.error || undefined, + cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file', + }; +} + +export function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + attachments: [], + }; +} + +/** + * 当前会话内新发送的用户消息还没有服务端数字 ID(本地为 msg_N 临时 key), + * 但紧随其后的 AI 回答会携带 replyToMessageId,可据此反推用户消息 ID。 + */ +export function resolveUserMessageId( + info: MessageInfo, + all: MessageInfo[], +): number | null { + if (typeof info.message.id === 'number') return info.message.id; + const index = all.findIndex((item) => item.id === info.id); + if (index === -1) return null; + for (const item of all.slice(index + 1)) { + if (typeof item.message.replyToMessageId === 'number') { + return item.message.replyToMessageId; + } + } + return null; +} + +export interface HoverActionItem { + key: string; + title: string; + icon: React.ReactNode; + danger?: boolean; + onClick: () => void; +} + +/** Codex Desktop 风格:hover 消息时在气泡外显示的纯图标操作,不包裹 Button */ +export function MessageHoverActions({ items }: { items: HoverActionItem[] }) { + return ( +
+ {items.map((item) => ( + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + item.onClick(); + } + }} + > + {item.icon} + + + ))} +
+ ); +} + +export const aiBubbleRoles: BubbleListProps['role'] = { + user: { placement: 'end', variant: 'filled', shape: 'corner' }, + assistant: { placement: 'start', variant: 'borderless' }, +}; diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx new file mode 100644 index 0000000..7ed21b6 --- /dev/null +++ b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { + CheckSquareOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PaperClipOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import Attachments from '@ant-design/x/es/attachments'; +import Conversations from '@ant-design/x/es/conversations'; +import Sender from '@ant-design/x/es/sender'; +import type { ConversationItemType } from '@ant-design/x'; +import type { AttachmentsProps } from '@ant-design/x/es/attachments'; +import { Button, Dropdown, Spin, Tooltip, Typography } from 'antd'; +import type { MenuProps } from 'antd'; +import type { AiSkill } from './types'; + +export interface AiChatSidebarProps { + className?: string; + conversationItems: ConversationItemType[]; + activeConversationKey?: string; + selectionMode: boolean; + selectedKeys: string[]; + loadingList: boolean; + conversationCount: number; + onActiveChange: (key: string) => void; + menu?: MenuProps | ((item: ConversationItemType) => MenuProps); + onStartNewConversation: () => void; + onSelectAll: () => void; + onInvertSelection: () => void; + onDeleteSelected: () => void; + onExitSelectionMode: () => void; + onEnterSelectionMode: () => void; +} + +export const AiChatSidebar: React.FC = ({ + className, + conversationItems, + activeConversationKey, + selectionMode, + selectedKeys, + loadingList, + conversationCount, + onActiveChange, + menu, + onStartNewConversation, + onSelectAll, + onInvertSelection, + onDeleteSelected, + onExitSelectionMode, + onEnterSelectionMode, +}) => { + return ( + + ); +}; + +export interface AiChatComposerProps { + conversationTitle: string; + input: string; + onChange: (value: string) => void; + isRequesting: boolean; + onSubmit: (value: string) => void; + onCancel: () => void; + uploadItems: AttachmentsProps['items']; + onCustomUpload: AttachmentsProps['customRequest']; + onRemoveAttachment: AttachmentsProps['onRemove']; + deepThinking: boolean; + onDeepThinkingChange: (value: boolean) => void; + lockedSkill?: AiSkill; + onClearSkill: () => void; + onToggleSidebar: () => void; + sidebarOpen: boolean; + skillMenu: MenuProps; +} + +export const AiChatComposer: React.FC = ({ + conversationTitle, + input, + onChange, + isRequesting, + onSubmit, + onCancel, + uploadItems, + onCustomUpload, + onRemoveAttachment, + deepThinking, + onDeepThinkingChange, + lockedSkill, + onClearSkill, + onToggleSidebar, + sidebarOpen, + skillMenu, +}) => { + return ( + <> +
+ + + +
+
+ { + // 中文输入法合成中的回车(确认候选词)不应触发发送。 + // 浏览器在 compositionend 后仍会派发 Enter keydown, + // 此时 Sender 内部的 composition 标记已失效,需用 + // KeyboardEvent.isComposing / keyCode 229 兜底。 + if (e.nativeEvent.isComposing || e.keyCode === 229) { + return false; + } + return undefined; + }} + autoSize={{ minRows: 1, maxRows: 6 }} + placeholder="询问学生、考勤、宿舍或账单数据" + skill={ + lockedSkill + ? { + title: lockedSkill.name, + value: lockedSkill.key, + closable: { onClose: onClearSkill }, + } + : undefined + } + header={ + (uploadItems ?? []).length > 0 && ( +
+ +
+ ) + } + footer={ +
+ + +
+ } + /> + + AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准 + +
+ + ); +}; diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 427e9bf..44101e4 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -1,152 +1,71 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - CheckSquareOutlined, DeleteOutlined, EditOutlined, ArrowRightOutlined, LoadingOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - PaperClipOutlined, - PlusOutlined, RobotOutlined, } from '@ant-design/icons'; +import Bubble from '@ant-design/x/es/bubble'; +import Prompts from '@ant-design/x/es/prompts'; +import Welcome from '@ant-design/x/es/welcome'; +import type { ConversationItemType } from '@ant-design/x'; +import { useXConversations } from '@ant-design/x-sdk'; import { - Attachments, - Bubble, - Conversations, - Prompts, - Sender, - SenderSwitch, - Welcome, -} from '@ant-design/x'; -import type { - BubbleItemType, - BubbleListProps, - ConversationItemType, - PromptsItemType, -} from '@ant-design/x'; -import type { Attachment } from '@ant-design/x/es/attachments'; -import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk'; -import { - Button, + App, Checkbox, Drawer, - Dropdown, Grid, Input, - Modal, - Spin, - Tooltip, - Typography, } from 'antd'; -import type { MenuProps, UploadFile, UploadProps } from 'antd'; +import type { MenuProps } from 'antd'; import { message } from '../../ui/app-message'; -import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, conversationStreamUrl } from './api'; -import { AiMessageContent } from './AiMessageContent'; -import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; -import type { - AiAttachment, - AiChatInput, - AiChatMessage, - AiChatMessageStatus, - AiConversation, - AiFormSchema, - AiReviewSchema, - AiReviewSection, - AiReviewSectionType, - AiSkill, - AiSseChunk, -} from './types'; +import { ImportWizardModal } from '../ImportWizard/ImportWizardModal'; +import type { AiSkill } from './types'; +import { useAiChatMessageActions } from './useAiChatMessageActions'; +import { AiChatComposer, AiChatSidebar } from './AiChatDrawer.parts'; +import { + aiBubbleRoles, + conversationStatusMeta, + sortConversations, + toConversationData, + type ConversationData, + type ConversationRunStatus, +} from './AiChatDrawer.helpers'; import './style.css'; +export { + aiBubbleRoles, + conversationStatusMeta, + type ConversationData, + type ConversationRunStatus, +} from './AiChatDrawer.helpers'; + interface AiChatDrawerProps { open: boolean; onClose: () => void; onRequestingChange?: (working: boolean) => void; } -interface ConversationData extends AiConversation { - key: string; - label: string; -} - -export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped'; - -export function conversationStatusMeta(status: ConversationRunStatus): { - label: string; - color: string; -} { - if (status === 'running') return { label: '生成中', color: 'processing' }; - if (status === 'done') return { label: '已完成', color: 'success' }; - if (status === 'error') return { label: '失败', color: 'error' }; - return { label: '已停止', color: 'default' }; -} - -function sortConversations(items: AiConversation[]): AiConversation[] { - return [...items].sort((a, b) => { - const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime(); - const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime(); - return bTime - aTime; - }); -} - -function toConversationData(item: AiConversation): ConversationData { - return { ...item, key: String(item.id), label: item.title }; -} - -function toUploadFile(attachment: AiAttachment): Attachment { - return { - uid: String(attachment.id), - name: attachment.name, - size: attachment.size, - status: attachment.status === 'ready' ? 'done' : attachment.status === 'failed' ? 'error' : 'uploading', - url: attachment.url, - response: attachment, - description: attachment.error || undefined, - cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file', - }; -} - -function emptyAssistant(): AiChatMessage { - return { - role: 'assistant', - content: '', - reasoningContent: '', - toolRuns: [], - attachments: [], - }; -} - -export const aiBubbleRoles: BubbleListProps['role'] = { - user: { placement: 'end', variant: 'filled', shape: 'corner' }, - assistant: { placement: 'start', variant: 'borderless' }, -}; - const AiChatDrawer: React.FC = ({ open, onClose, onRequestingChange }) => { + const { modal } = App.useApp(); const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(!isMobile); - const [input, setInput] = useState(''); + const effectiveSidebarOpen = isMobile ? false : sidebarOpen; const [skills, setSkills] = useState([]); - const [attachments, setAttachments] = useState([]); - const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking); - const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking); const [conversationStatus, setConversationStatus] = useState< Record >({}); + const [importWizardRunId, setImportWizardRunId] = useState(null); const [selectionMode, setSelectionMode] = useState(false); const [selectedKeys, setSelectedKeys] = useState([]); - const requestingRef = useRef(false); - const abortRef = useRef<() => void>(() => undefined); - const attachmentsRef = useRef([]); const requestAbortRef = useRef(new Map void>()); const providersRef = useRef(new Map()); const loadedRef = useRef(false); - const pendingDraftConversationIdRef = useRef(null); const { conversations, @@ -160,15 +79,16 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const activeConversationKeyRef = useRef(activeConversationKey); const activeConversation = useMemo( - () => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined, + () => + conversations.find((item) => item.key === activeConversationKey) as + | ConversationData + | undefined, [activeConversationKey, conversations], ); const activeId = activeConversation?.id ?? null; const lockedSkill = skills.find((skill) => skill.key === activeConversation?.lockedSkillKey); activeConversationKeyRef.current = activeConversationKey; - useEffect(() => setSidebarOpen(!isMobile), [isMobile]); - const refreshConversations = useCallback(async () => { const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData); setConversations(items); @@ -193,115 +113,53 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting [], ); - const provider = useMemo( - () => { - if (!activeId) return undefined; - const existing = providersRef.current.get(activeId); - if (existing) return existing; - const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => { - void refreshConversations(); - markConversationFinished(activeId, result); - }); - providersRef.current.set(activeId, created); - return created; - }, - [activeId, markConversationFinished, refreshConversations], - ); + const provider = useMemo(() => { + if (!activeId) return undefined; + const existing = providersRef.current.get(activeId); + if (existing) return existing; + const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => { + void refreshConversations(); + markConversationFinished(activeId, result); + }); + providersRef.current.set(activeId, created); + return created; + }, [activeId, markConversationFinished, refreshConversations]); - const { messages, onRequest, onReload, isRequesting, abort, setMessage, queueRequest } = useXChat< - AiChatMessage, - AiChatMessage, - AiChatInput, - AiSseChunk - >({ + const { + input, + setInput, + deepThinking, + setDeepThinking, + isRequesting, + messages, + stopRequest, + submit, + customUpload, + removeAttachment, + discardPendingAttachments, + uploadItems, + promptItems, + bubbleItems, + } = useAiChatMessageActions({ + activeConversation, + activeId, provider, - conversationKey: activeConversationKey || 'no-conversation', - defaultMessages: async () => { - if (!activeId) return []; - const page = await aiChatApi.listMessages(activeId); - return page.items.map(mapHistoryMessage); - }, - requestPlaceholder: emptyAssistant(), - requestFallback: ( - params: Partial, - { error, messageInfo }: { error: Error; messageInfo: MessageInfo }, - ) => ({ - ...(params.reloadMessage || messageInfo?.message || emptyAssistant()), - error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试', - cancelled: error.name === 'AbortError', - }), + requestAbortRef, + markConversationRunning, + addConversation, + setActiveConversationKey, + refreshConversations, + skills, + lockedSkill, + setImportWizardRunId, }); - useEffect(() => { - if (!provider) return; - provider.onExternalReview = (messageId, review) => { - setMessage(messageId, (info) => ({ - message: { - ...info.message, - reviews: (info.message.reviews ?? []).some((item) => item.id === review.id) - ? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item)) - : [...(info.message.reviews ?? []), review], - }, - })); - }; - }, [provider, setMessage]); - - requestingRef.current = isRequesting; - abortRef.current = abort; - attachmentsRef.current = attachments; - + // isRequesting 由 @ant-design/x-sdk 的 useXChat 内部维护且没有完成回调, + // 这里把它视为外部 SDK 状态做订阅转发,是 Effect 的合理用法。 useEffect(() => { onRequestingChange?.(isRequesting); }, [isRequesting, onRequestingChange]); - const stopRequest = useCallback(() => { - if (requestingRef.current) abortRef.current(); - }, []); - - const requestWithStatus = useCallback( - (params: AiChatInput) => { - if (!activeId || !provider) return; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - markConversationRunning(activeId); - onRequest(params); - }, - [activeId, markConversationRunning, onRequest, provider], - ); - - const reloadWithStatus = useCallback( - (messageInfo: MessageInfo) => { - if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - markConversationRunning(activeId); - onReload(messageInfo.id, { - message: '', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - regenerateMessageId: messageInfo.message.id, - reloadMessage: messageInfo.message, - }); - }, - [ - activeConversation?.lockedSkillKey, - activeId, - deepThinking, - markConversationRunning, - onReload, - provider, - ], - ); - - const discardPendingAttachments = useCallback(() => { - const pending = attachmentsRef.current; - attachmentsRef.current = []; - setAttachments([]); - for (const attachment of pending) { - void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined); - } - }, []); - useEffect(() => { if (!open || loadedRef.current) return; let cancelled = false; @@ -322,10 +180,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }; }, [open, setActiveConversationKey, setConversations]); - useEffect(() => { - discardPendingAttachments(); - if (isMobile) setSidebarOpen(false); - }, [activeConversationKey, discardPendingAttachments, isMobile]); + const switchConversation = useCallback( + (key: string) => { + discardPendingAttachments(); + if (isMobile) setSidebarOpen(false); + setActiveConversationKey(key); + }, + [discardPendingAttachments, isMobile, setActiveConversationKey], + ); useEffect( () => () => { @@ -338,17 +200,22 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting /** 新建对话(Codex 风格):先进入草稿态,发送第一条消息时才创建 session */ const startNewConversation = useCallback(() => { - setActiveConversationKey(''); - if (isMobile) setSidebarOpen(false); - }, [isMobile, setActiveConversationKey]); + switchConversation(''); + }, [switchConversation]); const renameConversation = useCallback( (conversation: ConversationData) => { let title = conversation.title; - Modal.confirm({ + modal.confirm({ title: '重命名会话', icon: , - content: (title = event.target.value)} />, + content: ( + (title = event.target.value)} + /> + ), okText: '保存', cancelText: '取消', onOk: async () => { @@ -383,7 +250,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const deleteConversation = useCallback( (conversation: ConversationData) => { - Modal.confirm({ + modal.confirm({ title: '删除会话', content: '该会话及全部历史消息将被永久删除。', okText: '删除', @@ -395,9 +262,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting removeConversation(conversation.key); const remaining = conversations.filter((item) => item.key !== conversation.key); if (!remaining.length) { - setActiveConversationKey(''); + switchConversation(''); } else if (conversation.id === activeId) { - setActiveConversationKey(remaining[0].key); + switchConversation(remaining[0].key); } }, }); @@ -405,9 +272,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting [ activeId, conversations, + switchConversation, removeConversation, removeConversationEntry, - setActiveConversationKey, ], ); @@ -443,7 +310,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting selectedKeys.includes(item.key), ) as ConversationData[]; if (!selected.length) return; - Modal.confirm({ + modal.confirm({ title: `删除选中的 ${selected.length} 个会话`, content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。', okText: '删除', @@ -457,7 +324,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting setConversationStatus({}); await aiChatApi.deleteAllConversations(); setConversations([]); - setActiveConversationKey(''); + switchConversation(''); } else { for (const item of selected) removeConversationEntry(item); const deletedKeys: string[] = []; @@ -477,9 +344,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const remaining = conversations.filter((item) => !deleted.has(item.key)); setConversations(remaining); if (!remaining.length) { - setActiveConversationKey(''); + switchConversation(''); } else if (activeId != null && !remaining.some((item) => item.id === activeId)) { - setActiveConversationKey(remaining[0].key); + switchConversation(remaining[0].key); } if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`); } @@ -493,7 +360,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting removeConversation, removeConversationEntry, selectedKeys, - setActiveConversationKey, + switchConversation, setConversations, ]); @@ -505,7 +372,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); - const conversation = conversations.find((entry) => entry.key === item.key) as ConversationData; + const conversation = conversations.find( + (entry) => entry.key === item.key, + ) as ConversationData; if (key === 'rename') renameConversation(conversation); if (key === 'delete') deleteConversation(conversation); }, @@ -521,252 +390,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting await aiChatApi.updateConversation(activeConversation.id, { lockedSkillKey: skillKey }), ); setConversation(activeConversation.key, updated); - } catch { + } catch (error) { + console.error('切换技能失败', error); message.error('切换技能失败'); } }, [activeConversation, setConversation], ); - const submit = useCallback( - (value: string) => { - const text = value.trim(); - if (!text || isRequesting) return; - const submittedAttachments = attachmentsRef.current; - attachmentsRef.current = []; - setAttachments([]); - setInput(''); - const params: AiChatInput = { - message: text, - attachmentIds: submittedAttachments.map((item) => item.id), - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - localAttachments: submittedAttachments, - }; - if (activeId != null) { - requestWithStatus(params); - return; - } - // 草稿态:先创建 session,再发送第一条消息 - void (async () => { - try { - const created = toConversationData(await aiChatApi.createConversation()); - addConversation(created, 'prepend'); - pendingDraftConversationIdRef.current = created.id; - markConversationRunning(created.id); - // 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出, - // 保证消息写入新会话的 store,界面能正常显示对话内容。 - queueRequest(created.key, params); - setActiveConversationKey(created.key); - } catch { - message.error('创建会话失败,请重试'); - attachmentsRef.current = submittedAttachments; - setAttachments(submittedAttachments); - setInput(text); - } - })(); - }, - [ - activeConversation?.lockedSkillKey, - activeId, - addConversation, - deepThinking, - isRequesting, - markConversationRunning, - queueRequest, - requestWithStatus, - setActiveConversationKey, - ], - ); - - // 草稿 session 创建完成、provider 就绪后注册中止句柄 - useEffect(() => { - if (activeId == null || !provider) return; - if (activeId !== pendingDraftConversationIdRef.current) return; - pendingDraftConversationIdRef.current = null; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - }, [activeId, provider]); - - const reloadMessage = useCallback( - (messageInfo: MessageInfo) => { - reloadWithStatus(messageInfo); - }, - [reloadWithStatus], - ); - - const submitForm = useCallback( - (form: AiFormSchema, values: Record) => { - if (!activeId || isRequesting) return; - requestWithStatus({ - message: '表单提交', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - formSubmission: { formId: form.id, values, formTitle: form.title }, - }); - }, - [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], - ); - - const submitReview = useCallback( - (reviewId: string, reviewTitle?: string) => { - if (!activeId || isRequesting) return; - requestWithStatus({ - message: '确认批量导入', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - reviewSubmission: { reviewId, reviewTitle }, - }); - }, - [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], - ); - - const confirmReviewStep = useCallback( - async ( - messageId: number | undefined, - reviewId: string, - sectionKey: AiReviewSection['key'], - ): Promise => { - const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey); - const apply = (review: AiReviewSchema) => { - if (provider?.onExternalReview && typeof messageId === 'number') { - provider.onExternalReview(messageId, review); - } else if (typeof messageId === 'number') { - setMessage(messageId, (info) => { - const reviews = info.message.reviews ?? []; - const exists = reviews.some((item) => item.id === review.id); - return { - message: { - ...info.message, - reviews: exists - ? reviews.map((item) => (item.id === review.id ? review : item)) - : [...reviews, review], - }, - }; - }); - } - }; - apply(updated); - return updated; - }, - [provider, setMessage], - ); - - const confirmReviewGroup = useCallback( - async ( - messageId: number | undefined, - reviewId: string, - type: AiReviewSectionType, - ): Promise => { - const updated = await aiChatApi.confirmReviewGroup(reviewId, type); - if (provider?.onExternalReview && typeof messageId === 'number') { - provider.onExternalReview(messageId, updated); - } else if (typeof messageId === 'number') { - setMessage(messageId, (info) => { - const reviews = info.message.reviews ?? []; - const exists = reviews.some((item) => item.id === updated.id); - return { - message: { - ...info.message, - reviews: exists - ? reviews.map((item) => (item.id === updated.id ? updated : item)) - : [...reviews, updated], - }, - }; - }); - } - return updated; - }, - [provider, setMessage], - ); - - const updateFeedback = useCallback( - async (messageInfo: MessageInfo, feedback: 'like' | 'dislike' | null) => { - if (typeof messageInfo.message.id !== 'number') return; - try { - await aiChatApi.setFeedback(messageInfo.message.id, feedback); - setMessage(messageInfo.id, { - message: { ...messageInfo.message, feedback }, - }); - } catch { - message.error('提交反馈失败'); - } - }, - [setMessage], - ); - - const customUpload = useCallback>(async (options) => { - const file = options.file as File; - if (attachmentsRef.current.length >= 5) { - const error = new Error('每条消息最多添加 5 个附件'); - options.onError?.(error); - message.warning(error.message); - return; - } - try { - const uploaded = await aiChatApi.uploadAttachment(file); - setAttachments((items) => [...items, uploaded]); - options.onSuccess?.(uploaded, file); - } catch (error) { - options.onError?.(error instanceof Error ? error : new Error('附件上传失败')); - message.error('附件上传失败'); - } - }, []); - - const removeAttachment = useCallback(async (file: UploadFile) => { - const attachment = file.response; - if (!attachment) return true; - try { - await aiChatApi.deleteAttachment(attachment.id); - setAttachments((items) => items.filter((item) => item.id !== attachment.id)); - return true; - } catch { - message.error('删除附件失败'); - return false; - } - }, []); - - const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]); - const promptItems = useMemo( - () => - (lockedSkill ? [lockedSkill] : skills) - .flatMap((skill) => skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example }))) - .slice(0, 5) - .map(({ skill, example }) => ({ - key: `${skill.key}-${example}`, - label: example, - description: skill.name, - })), - [lockedSkill, skills], - ); - - const bubbleItems = useMemo( - () => - messages.map((info) => ({ - key: info.id, - role: info.message.role === 'assistant' ? 'assistant' : 'user', - status: info.status, - content: info.message, - contentRender: (content: AiChatMessage) => ( - reloadMessage(info) : undefined} - onFeedback={content.role === 'assistant' ? (feedback) => void updateFeedback(info, feedback) : undefined} - onSubmitForm={submitForm} - onSubmitReview={submitReview} - onConfirmReviewStep={confirmReviewStep} - onConfirmReviewGroup={confirmReviewGroup} - /> - ), - })), - [confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback], - ); - const conversationItems = useMemo( () => conversations.map((item) => { @@ -822,83 +453,61 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting return ( 恭学 AI 助手} + title={ + + + 恭学 AI 助手 + + } open={open} closeIcon={} onClose={onClose} - width={isMobile ? '100%' : 'min(1040px, 92vw)'} + size={isMobile ? '100%' : 'min(1040px, 92vw)'} destroyOnHidden={false} className="ai-chat-drawer" styles={{ body: { padding: 0, height: '100%' } }} >
- + { + if (selectionMode) toggleConversationSelection(key); + else switchConversation(key); + }} + menu={conversationMenu} + onStartNewConversation={startNewConversation} + onSelectAll={selectAllConversations} + onInvertSelection={invertConversationSelection} + onDeleteSelected={deleteSelectedConversations} + onExitSelectionMode={exitSelectionMode} + onEnterSelectionMode={enterSelectionMode} + />
-
- - - -
+ void setLockedSkill(null)} + onToggleSidebar={() => setSidebarOpen((value) => !value)} + sidebarOpen={effectiveSidebarOpen} + skillMenu={skillMenu} + conversationTitle={activeConversation?.title || 'AI 助手'} + />
{messages.length ? ( @@ -909,7 +518,10 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting variant="borderless" icon={} title="你好,我是恭学 AI 助手" - description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'} + description={ + lockedSkill?.description || + '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' + } /> = ({ open, onClose, onRequesting )}
-
- void setLockedSkill(null) }, - } - : undefined - } - header={ - uploadItems.length > 0 && ( -
- -
- ) - } - footer={ -
- - -
- } + {importWizardRunId !== null && ( + setImportWizardRunId(null)} /> - - AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准 - -
+ )}
diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index ea4df02..d5b5346 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -1,39 +1,30 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { CheckCircleOutlined, CloseCircleOutlined, - CopyOutlined, - DislikeFilled, - DislikeOutlined, - LikeFilled, - LikeOutlined, LoadingOutlined, - ReloadOutlined, + TableOutlined, } from '@ant-design/icons'; -import { - Actions, - CodeHighlighter, - FileCard, - Mermaid, - Sources, - Think, - ThoughtChain, -} from '@ant-design/x'; +import FileCard from '@ant-design/x/es/file-card'; +import Sources from '@ant-design/x/es/sources'; +import Think from '@ant-design/x/es/think'; +import ThoughtChain from '@ant-design/x/es/thought-chain'; import type { ThoughtChainItemType } from '@ant-design/x'; -import XMarkdown from '@ant-design/x-markdown'; -import type { ComponentProps } from '@ant-design/x-markdown'; -import { Alert, Flex, Space, Typography } from 'antd'; +import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; +import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { LiteCodeHighlighter } from './LiteCodeHighlighter'; +import { LiteMermaid } from './LiteMermaid'; import type { AiAttachment, AiChatMessage, AiChatMessageStatus, AiChartSchema, AiFormSchema, - AiMessageFeedback, + AiImportWizard, AiReviewSection, AiReviewSchema, AiReviewSectionType, @@ -52,6 +43,7 @@ const toolLabels: Record = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', + start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', search_schedules: '查询课表', @@ -66,8 +58,8 @@ const markdownComponents = { code: ({ children, lang, block }: ComponentProps) => { const content = String(children ?? '').replace(/\n$/, ''); if (!block) return {content}; - if (lang === 'mermaid') return {content}; - return {content}; + if (lang === 'mermaid') return {content}; + return {content}; }, }; @@ -118,7 +110,8 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) { key: tool.toolCallId, title: toolLabels[tool.toolName] || tool.toolName, description: tool.durationMs ? `${tool.durationMs}ms` : undefined, - content: tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'), + content: + tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'), status: running ? 'loading' : success ? 'success' : 'error', icon: running ? ( @@ -135,11 +128,51 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) { return ; } +function EditUserContent({ + initial, + onConfirm, + onCancel, +}: { + initial: string; + onConfirm: (value: string) => void; + onCancel?: () => void; +}) { + const [draft, setDraft] = useState(initial); + return ( + + setDraft(event.target.value)} + autoSize={{ minRows: 2, maxRows: 8 }} + onKeyDown={(event) => { + // 中文输入法合成中的回车不应触发保存 + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + onConfirm(draft); + } else if (event.key === 'Escape') { + onCancel?.(); + } + }} + /> + + + + + + ); +} + export interface AiMessageContentProps { message: AiChatMessage; status?: AiChatMessageStatus; - onReload?: () => void; - onFeedback?: (feedback: AiMessageFeedback) => void; + editing?: boolean; + onEditConfirm?: (value: string) => void; + onEditCancel?: () => void; onSubmitForm?: (form: AiFormSchema, values: Record) => void; onSubmitReview?: (reviewId: string, reviewTitle?: string) => void; onConfirmReviewStep?: ( @@ -152,17 +185,20 @@ export interface AiMessageContentProps { reviewId: string, type: AiReviewSectionType, ) => AiReviewSchema | Promise | void; + onOpenImportWizard?: (runId: string) => void; } export const AiMessageContent: React.FC = ({ message, status, - onReload, - onFeedback, + editing, + onEditConfirm, + onEditCancel, onSubmitForm, onSubmitReview, onConfirmReviewStep, onConfirmReviewGroup, + onOpenImportWizard, }) => { const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; @@ -199,8 +235,8 @@ export const AiMessageContent: React.FC = ({ ? String((reviewSubmission as Record).reviewTitle) : '批量导入'; return ( - - + + ); } @@ -210,64 +246,55 @@ export const AiMessageContent: React.FC = ({ ? String((formSubmission as Record).formTitle) : '表单'; return ( - - + + ); } return ( - - {attachmentCards.length > 0 && {attachmentCards}} -
{message.content}
+ + {attachmentCards.length > 0 && ( + + {attachmentCards} + + )} + {editing ? ( + onEditConfirm?.(value)} + onCancel={onEditCancel} + /> + ) : ( +
{message.content}
+ )}
); } - const actionItems = [ - { - key: 'copy', - label: '复制', - icon: , - onItemClick: () => void navigator.clipboard.writeText(message.content), - }, - ...(onReload - ? [{ key: 'reload', label: '重新生成', icon: , onItemClick: onReload }] - : []), - ...(onFeedback - ? [ - { - key: 'like', - label: '有帮助', - icon: message.feedback === 'like' ? : , - onItemClick: () => onFeedback(message.feedback === 'like' ? null : 'like'), - }, - { - key: 'dislike', - label: '没帮助', - icon: message.feedback === 'dislike' ? : , - onItemClick: () => onFeedback(message.feedback === 'dislike' ? null : 'dislike'), - }, - ] - : []), - ]; - return ( - - {streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && ( -
- -
- )} + + {streaming && + !message.content && + !message.reasoningContent && + message.toolRuns.length === 0 && ( +
+ +
+ )} {message.retrying && ( )} {message.reasoningContent && ( - + = ({ )} {message.toolRuns.length > 0 && } - {attachmentCards.length > 0 && {attachmentCards}} + {attachmentCards.length > 0 && ( + + {attachmentCards} + + )} + {(() => { + const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; + if (!wizard || !onOpenImportWizard) return null; + return ( + + + + {wizard.fileName} + + + ); + })()} {message.content && ( = ({ {(message.charts ?? []).map((chart: AiChartSchema) => ( ))} - {message.error && } + {message.error && } {message.cancelled && 回答已停止} - {!streaming && message.content && }
); }; diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index 7d65542..fc9c78e 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Button, Tag, Tooltip, Typography } from 'antd'; +import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; +import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import type { EChartsType } from 'echarts/core'; -import ReactECharts, { type EChartsOption } from '../../components/ECharts'; +import type { EChartsOption } from '../../components/ECharts'; import type { AiChartSchema } from './types'; +// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 +const ReactECharts = lazy(() => import('../../components/ECharts')); + const CHART_CATALOG_ID = 'gongxue-chart-catalog'; registerCatalog({ @@ -41,117 +43,129 @@ const CHART_TYPE_LABELS: Record = { funnel: '漏斗图', }; -function buildOption(chart: AiChartSchema): EChartsOption { +function buildNameValueRows(chart: AiChartSchema): { name: string; value: number }[] { + const nameField = chart.columns[0]?.key ?? ''; + const valueField = chart.columns[1]?.key ?? ''; + return chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: numberValue(row[valueField]), + })); +} + +function buildScatterOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; - if (chart.chartType === 'scatter') { - const nameField = columns[0]?.key ?? ''; - const xField = columns[1]?.key ?? ''; - const yField = columns[2]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: [numberValue(row[xField]), numberValue(row[yField])], - })); - return { - tooltip: { - trigger: 'item', - formatter: (params: unknown) => { - const item = params as { name?: string; value?: number[] }; - const [x, y] = item.value ?? []; - return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`; - }, + const nameField = columns[0]?.key ?? ''; + const xField = columns[1]?.key ?? ''; + const yField = columns[2]?.key ?? ''; + const data = chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: [numberValue(row[xField]), numberValue(row[yField])], + })); + return { + tooltip: { + trigger: 'item', + formatter: (params: unknown) => { + const item = params as { name?: string; value?: number[] }; + const [x, y] = item.value ?? []; + return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`; }, - grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, - xAxis: { type: 'value', name: columns[1]?.title }, - yAxis: { type: 'value', name: columns[2]?.title }, - series: [{ type: 'scatter', symbolSize: 10, data }], - }; - } - if (chart.chartType === 'radar') { - const seriesNameField = columns[0]?.key ?? ''; - const indicatorColumns = columns.slice(1); - const indicators = indicatorColumns.map((column) => { - const values = chart.rows.map((row) => numberValue(row[column.key])); - const max = Math.max(1, ...values); - return { name: column.title, max: Math.ceil(max * 1.1) }; - }); - const seriesData = chart.rows.map((row) => ({ - name: String(row[seriesNameField] ?? ''), - value: indicatorColumns.map((column) => numberValue(row[column.key])), - })); - return { - tooltip: { trigger: 'item' }, - legend: { bottom: 0, type: 'scroll' }, - radar: { indicator: indicators, radius: '65%' }, - series: [{ type: 'radar', data: seriesData }], - }; - } - if (chart.chartType === 'gauge') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const maxField = columns[2]?.key; - const gauges = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - max: maxField ? Math.max(1, numberValue(row[maxField])) : 100, - })); - return { - series: gauges.map((gauge, index) => ({ - type: 'gauge', - center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'], - radius: '75%', - min: 0, - max: gauge.max, - title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 }, - detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] }, - data: [{ value: gauge.value, name: gauge.name }], - })), - }; - } - if (chart.chartType === 'funnel') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - })); - return { - tooltip: { trigger: 'item', formatter: '{b}: {c}' }, - legend: { bottom: 0, type: 'scroll' }, - series: [ - { - type: 'funnel', - left: '10%', - top: 20, - bottom: 40, - width: '80%', - minSize: '20%', - label: { formatter: '{b}: {c}' }, - data, - }, - ], - }; - } - if (chart.chartType === 'pie') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - })); - return { - tooltip: { trigger: 'item' }, - legend: { bottom: 0, type: 'scroll' }, - series: [ - { - type: 'pie', - radius: ['35%', '68%'], - center: ['50%', '45%'], - data, - label: { formatter: '{b}: {c}' }, - }, - ], - }; - } + }, + grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, + xAxis: { type: 'value', name: columns[1]?.title }, + yAxis: { type: 'value', name: columns[2]?.title }, + series: [{ type: 'scatter', symbolSize: 10, data }], + }; +} + +function buildRadarOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; + const seriesNameField = columns[0]?.key ?? ''; + const indicatorColumns = columns.slice(1); + const indicators = indicatorColumns.map((column) => { + const values = chart.rows.map((row) => numberValue(row[column.key])); + const max = Math.max(1, ...values); + return { name: column.title, max: Math.ceil(max * 1.1) }; + }); + const seriesData = chart.rows.map((row) => ({ + name: String(row[seriesNameField] ?? ''), + value: indicatorColumns.map((column) => numberValue(row[column.key])), + })); + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0, type: 'scroll' }, + radar: { indicator: indicators, radius: '65%' }, + series: [{ type: 'radar', data: seriesData }], + }; +} + +function buildGaugeOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; + const nameField = columns[0]?.key ?? ''; + const valueField = columns[1]?.key ?? ''; + const maxField = columns[2]?.key; + const gauges = chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: numberValue(row[valueField]), + max: maxField ? Math.max(1, numberValue(row[maxField])) : 100, + })); + return { + series: gauges.map((gauge, index) => ({ + type: 'gauge', + center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'], + radius: '75%', + min: 0, + max: gauge.max, + title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 }, + detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] }, + data: [{ value: gauge.value, name: gauge.name }], + })), + }; +} + +function buildNameValueOption(chart: AiChartSchema): EChartsOption { + const data = buildNameValueRows(chart); + return chart.chartType === 'funnel' + ? { + tooltip: { trigger: 'item', formatter: '{b}: {c}' }, + legend: { bottom: 0, type: 'scroll' }, + series: [ + { + type: 'funnel', + left: '10%', + top: 20, + bottom: 40, + width: '80%', + minSize: '20%', + label: { formatter: '{b}: {c}' }, + data, + }, + ], + } + : { + tooltip: { trigger: 'item' }, + legend: { bottom: 0, type: 'scroll' }, + series: [ + { + type: 'pie', + radius: ['35%', '68%'], + center: ['50%', '45%'], + data, + label: { formatter: '{b}: {c}' }, + }, + ], + }; +} + +function buildOption(chart: AiChartSchema): EChartsOption { + if (chart.chartType === 'scatter') return buildScatterOption(chart); + if (chart.chartType === 'radar') return buildRadarOption(chart); + if (chart.chartType === 'gauge') return buildGaugeOption(chart); + if (chart.chartType === 'funnel' || chart.chartType === 'pie') return buildNameValueOption(chart); + return buildCategoryOption(chart); +} + +function buildCategoryOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; const categoryField = columns[0]?.key ?? ''; const categories = chart.rows.map((row) => String(row[categoryField] ?? '')); const series = columns.slice(1).map((column) => ({ @@ -223,11 +237,9 @@ const ChartPreview: React.FC = ({ chart }) => {
- + }> + +
); }; @@ -291,5 +303,3 @@ export const DynamicChart: React.FC = ({ chart }) => { ); }; - -export default DynamicChart; diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index fd7411a..eadf163 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -1,7 +1,21 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd'; +import { + XCard, + registerCatalog, + type ActionPayload, + type XAgentCommand_v0_9, +} from '@ant-design/x-card'; +import { + Alert, + Button, + DatePicker, + Flex, + Form, + Input, + InputNumber, + Select, + Typography, +} from 'antd'; import dayjs from 'dayjs'; import type { AiFormField, AiFormSchema } from './types'; @@ -47,7 +61,11 @@ function normalizeValues( } interface FormPreviewProps { - form?: AiFormSchema; + form?: AiFormSchema & { + submitting?: boolean; + submitted?: boolean; + error?: string | null; + }; disabled?: boolean; onAction?: (name: string, context: Record) => void; } @@ -58,18 +76,14 @@ interface FormPreviewProps { * normalized values back through the `form:submit` action. */ const FormPreview: React.FC = ({ form, disabled, onAction }) => { - const runtime = form as unknown as { - submitting?: boolean; - submitted?: boolean; - error?: string | null; - }; - const submitting = Boolean(runtime.submitting); + const submitting = Boolean(form?.submitting); const initialValues = useMemo( - () => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])), + () => + Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])), [form?.fields], ); if (!form) return null; - const finished = Boolean(runtime.submitted) || form.status === 'submitted'; + const finished = Boolean(form.submitted) || form.status === 'submitted'; const handleFinish = (values: Record) => { onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); @@ -124,17 +138,20 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = options={field.options} /> ) : field.type === 'date' ? ( - + ) : ( )} ))} - {runtime.error && ( + {form.error && ( )} @@ -235,5 +252,3 @@ export const DynamicForm: React.FC = ({ form, disabled, onSubm ); }; - -export default DynamicForm; diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx index 1c303a5..fb4485c 100644 --- a/apps/admin/src/components/AiChat/DynamicReview.tsx +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -1,15 +1,35 @@ import React, { useEffect, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Alert, Button, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd'; -import type { TableProps } from 'antd'; -import type { - AiReviewRow, - AiReviewSchema, - AiReviewSection, - AiReviewSectionStatus, - AiReviewSectionType, -} from './types'; +import { + XCard, + registerCatalog, + type ActionPayload, + type XAgentCommand_v0_9, +} from '@ant-design/x-card'; +import { + Alert, + Button, + Flex, + Popconfirm, + Steps, + Table, + Tag, + Typography, + type TableProps, +} from 'antd'; +import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types'; +import { + GROUP_STATUS_LABELS, + SECTION_ORDER, + SECTION_STATUS_LABELS, + SECTION_TYPE_LABELS, + dependencyHint, + groupSections, + groupStatus, + sectionCount, + sectionResultText, + sectionStatus, + sectionType, +} from './reviewSection'; const REVIEW_CATALOG_ID = 'gongxue-review-catalog'; @@ -35,125 +55,6 @@ function surfaceId(reviewId: string): string { return `review-${reviewId}`; } -const SECTION_TYPE_LABELS: Record = { - students: '学生', - rooms: '宿舍', - transfers: '换宿', - checkins: '入住记录', -}; - -const SECTION_ORDER: AiReviewSectionType[] = [ - 'students', - 'rooms', - 'transfers', - 'checkins', -]; - -const SECTION_DEPENDENCIES: Record = { - students: [], - rooms: [], - transfers: ['students', 'rooms'], - checkins: [], -}; - -function sectionType(section: Pick): AiReviewSectionType { - if ( - section.type === 'students' || - section.type === 'rooms' || - section.type === 'transfers' || - section.type === 'checkins' - ) { - return section.type; - } - const key = section.key as AiReviewSectionType; - if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') { - return key; - } - const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`)); - return prefix ?? 'students'; -} - -function sectionCount(section: AiReviewSection): number { - return section.rows.length; -} - -function sectionStatus(section: AiReviewSection): AiReviewSectionStatus { - return section.status ?? 'pending'; -} - -function sectionResultText(section: AiReviewSection): string { - if (!section.resultSummary) return ''; - try { - const parsed = JSON.parse(section.resultSummary) as { message?: unknown }; - if (typeof parsed.message === 'string') return parsed.message; - } catch { - // Older data may store a plain text summary. - } - return section.resultSummary; -} - -const SECTION_STATUS_LABELS: Record = { - pending: '待确认', - submitted: '已导入', - failed: '失败', - skipped: '已跳过', -}; - -type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing'; - -const GROUP_STATUS_LABELS: Record = { - pending: '待确认', - partial: '部分完成', - submitted: '已导入', - failed: '失败', - importing: '导入中', -}; - -function groupSections( - sections: AiReviewSection[], - type: AiReviewSectionType, -): AiReviewSection[] { - return sections.filter((section) => sectionType(section) === type); -} - -function groupStatus( - sections: AiReviewSection[], - type: AiReviewSectionType, - submittingKey: string | null, - submittingGroup: boolean, - activeType?: AiReviewSectionType, -): GroupStatus { - const items = groupSections(sections, type); - if (items.length === 0) return 'pending'; - if ( - (submittingGroup && type === activeType) || - items.some((item) => submittingKey === item.key) - ) { - return 'importing'; - } - if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed'; - if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted'; - return 'partial'; -} - -function dependencyHint( - sections: AiReviewSection[], - type: AiReviewSectionType, -): { step: number; title: string } | null { - for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) { - const matches = groupSections(sections, dependencyType); - if (matches.length === 0) { - return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] }; - } - for (const section of matches) { - if (sectionStatus(section) !== 'submitted') { - return { step: sections.indexOf(section), title: section.title }; - } - } - } - return null; -} - function errorMessage(reason: unknown): string { if (reason instanceof Error) return reason.message; if (reason && typeof reason === 'object' && 'message' in reason) { @@ -188,7 +89,14 @@ function SectionTable({ section }: { section: AiReviewSection }) { } interface ReviewPreviewProps { - review?: AiReviewSchema; + review?: AiReviewSchema & { + submitting?: boolean; + activeKey?: string; + activeType?: AiReviewSectionType; + submittingKey?: string | null; + submittingGroup?: boolean; + error?: string | null; + }; disabled?: boolean; onAction?: (name: string, context: Record) => void; } @@ -197,27 +105,19 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio if (!review) return null; const submitted = review.status === 'submitted'; const expired = review.status === 'expired'; - const runtime = review as unknown as { - submitting?: boolean; - activeKey?: string; - activeType?: string; - submittingKey?: string | null; - submittingGroup?: boolean; - error?: string | null; - }; - const submitting = Boolean(runtime.submitting); - const submittingKey = runtime.submittingKey ?? null; - const submittingGroup = Boolean(runtime.submittingGroup); + const submitting = Boolean(review.submitting); + const submittingKey = review.submittingKey ?? null; + const submittingGroup = Boolean(review.submittingGroup); const sections = review.sections; const presentTypes = SECTION_ORDER.filter((type) => sections.some((section) => sectionType(section) === type), ); - const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType) - ? (runtime.activeType as AiReviewSectionType) + const activeType = presentTypes.includes(review.activeType as AiReviewSectionType) + ? (review.activeType as AiReviewSectionType) : presentTypes[0]; if (!activeType) return null; const activeSection = - sections.find((section) => section.key === runtime.activeKey) ?? + sections.find((section) => section.key === review.activeKey) ?? groupSections(sections, activeType)[0]; const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending'; const dependency = @@ -289,7 +189,10 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio )} item.key === activeType))} + current={Math.max( + 0, + typeItems.findIndex((item) => item.key === activeType), + )} items={typeItems.map((item) => ({ key: item.key, title: item.title, @@ -309,16 +212,18 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio {SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行 - {GROUP_STATUS_LABELS[ - groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) - ]} + { + GROUP_STATUS_LABELS[ + groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) + ] + } {groupDep && ( = ({ review, disabled, onActio }) } > - )} {submitted && } - {runtime.error && ( + {review.error && ( )} @@ -525,6 +426,8 @@ export const DynamicReview: React.FC = ({ const [submittingGroup, setSubmittingGroup] = useState(false); const [activeKey, setActiveKey] = useState(undefined); const [activeType, setActiveType] = useState(undefined); + const activeTypeRef = useRef(activeType); + activeTypeRef.current = activeType; const [localReview, setLocalReview] = useState(review); const [error, setError] = useState(null); const commandsRef = useRef([]); @@ -537,7 +440,9 @@ export const DynamicReview: React.FC = ({ review.sections.some((section) => sectionType(section) === type), ); const preferredType = - activeType && types.includes(activeType) ? activeType : types[0]; + activeTypeRef.current && types.includes(activeTypeRef.current) + ? activeTypeRef.current + : types[0]; setActiveType(preferredType); setActiveKey((current) => current && @@ -547,7 +452,7 @@ export const DynamicReview: React.FC = ({ ? current : review.sections.find((section) => sectionType(section) === preferredType)?.key, ); - }, [activeType, review]); + }, [review]); useEffect(() => { const sid = surfaceId(localReview.id); @@ -593,7 +498,16 @@ export const DynamicReview: React.FC = ({ }, }); setCommands([...cmds]); - }, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]); + }, [ + activeKey, + activeType, + disabled, + error, + localReview, + submitting, + submittingGroup, + submittingKey, + ]); const handleSubmit = async (reviewId: string) => { if (submitting) return; @@ -639,8 +553,7 @@ export const DynamicReview: React.FC = ({ const handleAction = (payload: ActionPayload) => { const context = payload.context ?? {}; if (payload.name === 'review:submit') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; void handleSubmit(reviewId); return; } @@ -648,33 +561,27 @@ export const DynamicReview: React.FC = ({ const type = context.type as AiReviewSectionType | undefined; if (type && SECTION_ORDER.includes(type)) { setActiveType(type); - setActiveKey( - localReview.sections.find((section) => sectionType(section) === type)?.key, - ); + setActiveKey(localReview.sections.find((section) => sectionType(section) === type)?.key); } return; } if (payload.name === 'review:selectStep') { if (typeof context.sectionKey === 'string') { - const section = localReview.sections.find( - (item) => item.key === context.sectionKey, - ); + const section = localReview.sections.find((item) => item.key === context.sectionKey); setActiveKey(context.sectionKey); if (section) setActiveType(sectionType(section)); } return; } if (payload.name === 'review:confirmStep') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; if (typeof context.sectionKey === 'string') { void handleConfirmStep(reviewId, context.sectionKey); } return; } if (payload.name === 'review:confirmGroup') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; const type = context.type as AiReviewSectionType | undefined; if (type && SECTION_ORDER.includes(type)) { void handleConfirmGroup(reviewId, type); @@ -684,16 +591,10 @@ export const DynamicReview: React.FC = ({ return (
- + - {error && } + {error && }
); }; - -export default DynamicReview; diff --git a/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx b/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx new file mode 100644 index 0000000..a3e13b6 --- /dev/null +++ b/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx @@ -0,0 +1,49 @@ +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; +import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; +import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; +import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'; +import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'; + +// 只注册 AI 对话里常用的语言,避免 @ant-design/x 的 CodeHighlighter +// 把所有 prism 语言都打进主包 +SyntaxHighlighter.registerLanguage('tsx', tsx); +SyntaxHighlighter.registerLanguage('typescript', typescript); +SyntaxHighlighter.registerLanguage('javascript', javascript); +SyntaxHighlighter.registerLanguage('json', json); +SyntaxHighlighter.registerLanguage('bash', bash); +SyntaxHighlighter.registerLanguage('shell', bash); +SyntaxHighlighter.registerLanguage('sql', sql); +SyntaxHighlighter.registerLanguage('css', css); + +const SUPPORTED_LANGUAGES = new Set([ + 'tsx', + 'typescript', + 'javascript', + 'json', + 'bash', + 'shell', + 'sql', + 'css', +]); + +interface LiteCodeHighlighterProps { + lang?: string; + children: string; +} + +export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) { + const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined; + return ( + + {children} + + ); +} diff --git a/apps/admin/src/components/AiChat/LiteMermaid.tsx b/apps/admin/src/components/AiChat/LiteMermaid.tsx new file mode 100644 index 0000000..be2e32c --- /dev/null +++ b/apps/admin/src/components/AiChat/LiteMermaid.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; + +interface LiteMermaidProps { + children: string; +} + +/** + * 轻量 Mermaid 渲染:动态 import mermaid,只有出现 mermaid 代码块时才加载 + * mermaid 及其解析器/图布局依赖,避免随 AI 抽屉主包一起加载。 + */ +export function LiteMermaid({ children }: LiteMermaidProps) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + if (!container) return; + + void (async () => { + try { + const mermaid = (await import('mermaid')).default; + mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' }); + const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children); + if (!cancelled) { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + container.replaceChildren(doc.documentElement); + setError(null); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : '图表渲染失败'); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [children]); + + if (error) { + return ( +
{children}
+ ); + } + return
; +} diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index 68441e4..f336abc 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -3,7 +3,6 @@ import type { AiApiResponse, AiAttachment, AiConversation, - AiMessageFeedback, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -15,8 +14,7 @@ const basePath = '/ai/chat/conversations'; export const aiChatApi = { listSkills: async () => (await api.get>('/ai/chat/skills')).data, - listConversations: async () => - (await api.get>(basePath)).data, + listConversations: async () => (await api.get>(basePath)).data, createConversation: async (input?: { title?: string; lockedSkillKey?: string | null }) => (await api.post>(basePath, input ?? {})).data, updateConversation: async ( @@ -26,6 +24,12 @@ export const aiChatApi = { deleteConversation: (id: number) => api.delete(`${basePath}/${id}`), deleteAllConversations: async () => (await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data, + deleteMessage: async (conversationId: number, messageId: number) => + ( + await api.delete>( + `${basePath}/${conversationId}/messages/${messageId}`, + ) + ).data, uploadAttachment: async (file: File): Promise => { const form = new FormData(); form.append('file', file); @@ -37,17 +41,6 @@ export const aiChatApi = { ).data; }, deleteAttachment: (id: number) => api.delete(`/ai/chat/attachments/${id}`), - setFeedback: async ( - messageId: number, - feedback: AiMessageFeedback, - reason?: string, - ) => - ( - await api.patch>( - `/ai/chat/messages/${messageId}/feedback`, - { feedback, reason }, - ) - ).data, confirmReviewStep: async ( reviewId: string, sectionKey: AiReviewSection['key'], @@ -90,7 +83,3 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } - -export function regenerateStreamUrl(conversationId: number, messageId: number): string { - return `/api${basePath}/${conversationId}/messages/${messageId}/regenerate/stream`; -} diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index eb11697..0aa813a 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -11,7 +11,6 @@ describe('AI chat history mapper', () => { status: 'completed', errorCode: null, createdAt: '2026-07-23T00:00:00.000Z', - feedback: 'like', attachments: [ { id: 8, @@ -37,7 +36,6 @@ describe('AI chat history mapper', () => { expect(mapped.message.reasoningContent).toBe('思考'); expect(mapped.message.toolRuns[0].summary).toBe('共 4 间'); expect(mapped.message.attachments).toHaveLength(1); - expect(mapped.message.feedback).toBe('like'); }); it('maps failed and cancelled history to X SDK statuses', () => { diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index 2ee555e..e3a1390 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -65,8 +65,6 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' }); }); - it('tracks processed attachments and final feedback state', () => { + it('tracks processed attachments and final message state', () => { let message = reduceAiSseMessage(undefined, { event: 'attachment.processed', data: JSON.stringify({ @@ -66,13 +66,11 @@ describe('AI chat SSE message reducer', () => { id: 12, content: '完成', reasoningContent: null, - feedback: 'like', attachments: message.attachments, }, }), }); expect(message.attachments).toHaveLength(1); - expect(message.feedback).toBe('like'); }); it('uses final content and records cancellation and errors', () => { diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 3467a60..ad93b6d 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -35,6 +35,7 @@ interface AiSsePayload { form?: AiFormSchema; review?: AiReviewSchema; chart?: AiChartSchema; + wizard?: unknown; retry?: AiModelRetryInfo; message?: | string @@ -46,8 +47,6 @@ interface AiSsePayload { toolRuns?: AiToolRun[]; attachments?: AiAttachment[]; replyToMessageId?: number | null; - feedback?: 'like' | 'dislike' | null; - feedbackReason?: string | null; metadata?: Record | null; }; error?: string; @@ -79,29 +78,10 @@ function mergeForms( return next; } -function mergeReviews( - current: AiReviewSchema[] | undefined, - incoming: AiReviewSchema | AiReviewSchema[] | undefined, -): AiReviewSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const index = next.findIndex((existing) => existing.id === item.id); - if (index === -1) { - next.push(item); - } else { - next[index] = item; - } - } - return next; -} - -function mergeCharts( - current: AiChartSchema[] | undefined, - incoming: AiChartSchema | AiChartSchema[] | undefined, -): AiChartSchema[] { +function mergeById( + current: T[] | undefined, + incoming: T | T[] | undefined, +): T[] { const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; if (!items.length) return current ?? []; const next = [...(current ?? [])]; @@ -165,6 +145,28 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu })); } +function applyMessagePayload( + message: AiChatMessage, + nested: AiSsePayload['message'], + payload: AiSsePayload, +): void { + if (typeof nested !== 'object' || nested === null) return; + message.forms = mergeForms( + message.forms, + (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + ); + message.reviews = mergeById( + message.reviews, + (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + ); + message.charts = mergeById( + message.charts, + (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + ); + message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; + message.metadata = nested.metadata ?? message.metadata; +} + export function reduceAiSseMessage( originMessage: AiChatMessage | undefined, chunk?: AiSseChunk, @@ -179,22 +181,7 @@ export function reduceAiSseMessage( message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - message.forms = mergeForms( - message.forms, - (nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeReviews( - message.reviews, - (nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeCharts( - message.charts, - (nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId; - message.feedback = nested?.feedback ?? message.feedback; - message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason; - message.metadata = nested?.metadata ?? message.metadata; + applyMessagePayload(message, nested, payload); } else if (event === 'reasoning.delta') { message.retrying = null; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; @@ -206,9 +193,11 @@ export function reduceAiSseMessage( } else if (event === 'ui.form' && payload.form) { message.forms = mergeForms(message.forms, payload.form); } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeReviews(message.reviews, payload.review); + message.reviews = mergeById(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeCharts(message.charts, payload.chart); + message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.import_wizard' && payload.wizard) { + message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; } else if (event === 'tool.started') { message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); } else if (event === 'tool.completed') { @@ -227,22 +216,7 @@ export function reduceAiSseMessage( nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - message.forms = mergeForms( - message.forms, - (nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeReviews( - message.reviews, - (nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeCharts( - message.charts, - (nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId; - message.feedback = nested?.feedback ?? message.feedback; - message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason; - message.metadata = nested?.metadata ?? message.metadata; + applyMessagePayload(message, nested, payload); message.retrying = null; } else if (event === 'message.cancelled') { message.id = payload.messageId ?? message.id; @@ -280,6 +254,16 @@ export async function authenticatedFetch( reasoningEffort: body.reasoningEffort, }), }; + } else if (body.editMessageId) { + requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.editMessageId}/edit/stream`; + requestInit = { + ...init, + body: JSON.stringify({ + content: body.message, + clientRequestId: body.clientRequestId, + reasoningEffort: body.reasoningEffort, + }), + }; } else if (body.formSubmission) { requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`; requestInit = { @@ -304,6 +288,7 @@ export async function authenticatedFetch( localAttachments: _localAttachments, reloadMessage: _reloadMessage, regenerateMessageId: _regenerateMessageId, + editMessageId: _editMessageId, formSubmission: _formSubmission, reviewSubmission: _reviewSubmission, ...payload @@ -331,10 +316,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider< /** Routes events that target another (already streamed) message. */ onExternalReview?: (messageId: number, review: AiReviewSchema) => void; - constructor( - url: string, - onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void, - ) { + constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { super({ request: XRequest(url, { manual: true, @@ -369,11 +351,16 @@ export class GongxueAiChatProvider extends AbstractChatProvider< formSubmission: requestParams.formSubmission, reviewSubmission: requestParams.reviewSubmission, regenerateMessageId: requestParams.regenerateMessageId, + editMessageId: requestParams.editMessageId, reloadMessage: requestParams.reloadMessage, }; } - transformLocalMessage(requestParams: Partial): AiChatMessage { + transformLocalMessage(requestParams: Partial): AiChatMessage | AiChatMessage[] { + if (requestParams.editMessageId) { + // 编辑消息不需要新增用户气泡,store 里已原位更新原消息。 + return []; + } if (requestParams.formSubmission) { return { role: 'user', diff --git a/apps/admin/src/components/AiChat/reviewSection.ts b/apps/admin/src/components/AiChat/reviewSection.ts new file mode 100644 index 0000000..b50290e --- /dev/null +++ b/apps/admin/src/components/AiChat/reviewSection.ts @@ -0,0 +1,115 @@ +import type { AiReviewSection, AiReviewSectionStatus, AiReviewSectionType } from './types'; + +export const SECTION_TYPE_LABELS: Record = { + students: '学生', + rooms: '宿舍', + transfers: '换宿', + checkins: '入住记录', +}; + +export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins']; + +const SECTION_DEPENDENCIES: Record = { + students: [], + rooms: [], + transfers: ['students', 'rooms'], + checkins: [], +}; + +export function sectionType(section: Pick): AiReviewSectionType { + if ( + section.type === 'students' || + section.type === 'rooms' || + section.type === 'transfers' || + section.type === 'checkins' + ) { + return section.type; + } + const key = section.key as AiReviewSectionType; + if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') { + return key; + } + const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`)); + return prefix ?? 'students'; +} + +export function sectionCount(section: AiReviewSection): number { + return section.rows.length; +} + +export function sectionStatus(section: AiReviewSection): AiReviewSectionStatus { + return section.status ?? 'pending'; +} + +export function sectionResultText(section: AiReviewSection): string { + if (!section.resultSummary) return ''; + try { + const parsed = JSON.parse(section.resultSummary) as { message?: unknown }; + if (typeof parsed.message === 'string') return parsed.message; + } catch { + // Older data may store a plain text summary. + } + return section.resultSummary; +} + +export const SECTION_STATUS_LABELS: Record = { + pending: '待确认', + submitted: '已导入', + failed: '失败', + skipped: '已跳过', +}; + +export type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing'; + +export const GROUP_STATUS_LABELS: Record = { + pending: '待确认', + partial: '部分完成', + submitted: '已导入', + failed: '失败', + importing: '导入中', +}; + +export function groupSections( + sections: AiReviewSection[], + type: AiReviewSectionType, +): AiReviewSection[] { + return sections.filter((section) => sectionType(section) === type); +} + +export function groupStatus( + sections: AiReviewSection[], + type: AiReviewSectionType, + submittingKey: string | null, + submittingGroup: boolean, + activeType?: AiReviewSectionType, +): GroupStatus { + const items = groupSections(sections, type); + if (items.length === 0) return 'pending'; + if ( + (submittingGroup && type === activeType) || + items.some((item) => submittingKey === item.key) + ) { + return 'importing'; + } + if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed'; + if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted'; + return 'partial'; +} + +export function dependencyHint( + sections: AiReviewSection[], + type: AiReviewSectionType, +): { step: number; title: string } | null { + for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) { + const matches = groupSections(sections, dependencyType); + if (matches.length === 0) { + return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] }; + } + for (const section of matches) { + if (sectionStatus(section) !== 'submitted') { + return { step: sections.indexOf(section), title: section.title }; + } + } + } + return null; +} diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index f79f073..14a1e44 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -207,10 +207,68 @@ padding: 20px clamp(16px, 4vw, 48px); } +.ai-chat-messages .ant-bubble { + position: relative; +} + .ai-chat-messages .ant-bubble-content { max-width: min(100%, 680px); } +.ai-chat-messages .ant-bubble-extra { + position: absolute; + top: 2px; + right: 10px; + z-index: 2; +} + +.ai-chat-hover-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + background: rgba(255, 255, 255, 0.94); + border: 1px solid #eceef2; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); + opacity: 0; + transform: translateY(-3px); + transition: + opacity 0.15s ease, + transform 0.15s ease; + pointer-events: none; +} + +.ai-chat-messages .ant-bubble:hover .ai-chat-hover-actions, +.ai-chat-hover-actions:focus-within { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.ai-chat-hover-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + color: #5f6672; + font-size: 14px; + cursor: pointer; + user-select: none; +} + +.ai-chat-hover-action:hover { + background: #f0f2f5; + color: #1f2329; +} + +.ai-chat-hover-action.is-danger:hover { + background: #fff1f0; + color: #cf1322; +} + .ai-chat-user-text { max-width: 100%; overflow-wrap: anywhere; @@ -221,6 +279,10 @@ max-width: 100%; } +.ai-chat-user-edit { + width: min(520px, 100%); +} + .ai-chat-answer { width: 100%; min-width: 0; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index b46d404..a0964b1 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -98,6 +98,23 @@ export interface AiChartSchema { rows: AiReviewRow[]; } +export interface AiImportWizard { + runId: string; + fileName: string; + sheets: Array<{ + name: string; + suggestedStepKey?: AiReviewSectionType | null; + headers: string[]; + rowCount: number; + }>; + steps: Array<{ + stepKey: AiReviewSectionType; + label: string; + sheets: string[]; + status: string; + }>; +} + export type AiToolRunStatus = | 'running' | 'success' @@ -126,7 +143,6 @@ export interface AiModelRetryInfo { } export type AiMessageRole = 'user' | 'assistant'; -export type AiMessageFeedback = 'like' | 'dislike' | null; export interface AiChatMessage { id?: number | string; @@ -139,8 +155,6 @@ export interface AiChatMessage { reviews?: AiReviewSchema[]; charts?: AiChartSchema[]; replyToMessageId?: number | null; - feedback?: AiMessageFeedback; - feedbackReason?: string | null; metadata?: Record | null; retrying?: AiModelRetryInfo | null; error?: string; @@ -155,8 +169,6 @@ export interface AiMessageRecord { status: 'pending' | 'completed' | 'failed' | 'cancelled'; errorCode: string | null; replyToMessageId?: number | null; - feedback?: AiMessageFeedback; - feedbackReason?: string | null; metadata?: Record | null; attachments?: AiAttachment[]; createdAt: string; @@ -176,6 +188,7 @@ export interface AiChatInput { skillKey: string | null; clientRequestId: string; reasoningEffort?: string | null; + editMessageId?: number; localAttachments?: AiAttachment[]; formSubmission?: { formId: string; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx new file mode 100644 index 0000000..274633c --- /dev/null +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; +import { CopyOutlined, DeleteOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { BubbleItemType, PromptsItemType } from '@ant-design/x'; +import { useXChat, type MessageInfo } from '@ant-design/x-sdk'; +import { App } from 'antd'; +import type { UploadFile, UploadProps } from 'antd'; +import { message } from '../../ui/app-message'; +import { useSettingsStore } from '../../store/settings/settingsStore'; +import { aiChatApi } from './api'; +import { AiMessageContent } from './AiMessageContent'; +import { mapHistoryMessage } from './message-mappers'; +import { GongxueAiChatProvider } from './provider'; +import { + emptyAssistant, + MessageHoverActions, + resolveUserMessageId, + toConversationData, + toUploadFile, + type ConversationData, +} from './AiChatDrawer.helpers'; +import type { + AiAttachment, + AiChatInput, + AiChatMessage, + AiChatMessageStatus, + AiFormSchema, + AiReviewSchema, + AiReviewSection, + AiReviewSectionType, + AiSkill, + AiSseChunk, +} from './types'; + +interface UseAiChatMessageActionsParams { + activeConversation: ConversationData | undefined; + activeId: number | null; + provider: GongxueAiChatProvider | undefined; + requestAbortRef: MutableRefObject void>>; + markConversationRunning: (conversationId: number) => void; + addConversation: (conversation: ConversationData, placement?: 'prepend' | 'append') => boolean; + setActiveConversationKey: (key: string) => boolean; + refreshConversations: () => Promise; + skills: AiSkill[]; + lockedSkill: AiSkill | undefined; + setImportWizardRunId: (runId: string | null) => void; +} + +export function useAiChatMessageActions({ + activeConversation, + activeId, + provider, + requestAbortRef, + markConversationRunning, + addConversation, + setActiveConversationKey, + refreshConversations, + skills, + lockedSkill, + setImportWizardRunId, +}: UseAiChatMessageActionsParams) { + const { modal } = App.useApp(); + const [input, setInput] = useState(''); + const [attachments, setAttachments] = useState([]); + const [editingMessageId, setEditingMessageId] = useState(null); + const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking); + const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking); + const requestingRef = useRef(false); + const abortRef = useRef<() => void>(() => undefined); + const attachmentsRef = useRef([]); + const pendingDraftConversationIdRef = useRef(null); + const messagesRef = useRef[]>([]); + + const { + messages, + onRequest, + onReload, + isRequesting, + abort, + setMessage, + removeMessage, + queueRequest, + } = useXChat({ + provider, + conversationKey: activeConversation?.key || 'no-conversation', + defaultMessages: async () => { + if (!activeId) return []; + const page = await aiChatApi.listMessages(activeId); + return page.items.map(mapHistoryMessage); + }, + requestPlaceholder: emptyAssistant(), + requestFallback: ( + params: Partial, + { error, messageInfo }: { error: Error; messageInfo: MessageInfo }, + ) => ({ + ...(params.reloadMessage || messageInfo?.message || emptyAssistant()), + error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试', + cancelled: error.name === 'AbortError', + }), + }); + + useEffect(() => { + if (!provider) return; + provider.onExternalReview = (messageId, review) => { + setMessage(messageId, (info) => ({ + message: { + ...info.message, + reviews: (info.message.reviews ?? []).some((item) => item.id === review.id) + ? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item)) + : [...(info.message.reviews ?? []), review], + }, + })); + }; + }, [provider, setMessage]); + + requestingRef.current = isRequesting; + abortRef.current = abort; + attachmentsRef.current = attachments; + messagesRef.current = messages; + + const stopRequest = useCallback(() => { + if (requestingRef.current) abortRef.current(); + }, []); + + const requestWithStatus = useCallback( + (params: AiChatInput) => { + if (!activeId || !provider) return; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + markConversationRunning(activeId); + onRequest(params); + }, + [activeId, markConversationRunning, onRequest, provider, requestAbortRef], + ); + + const reloadWithStatus = useCallback( + (messageInfo: MessageInfo) => { + if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + markConversationRunning(activeId); + onReload(messageInfo.id, { + message: '', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + regenerateMessageId: messageInfo.message.id, + reloadMessage: messageInfo.message, + }); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + deepThinking, + markConversationRunning, + onReload, + provider, + requestAbortRef, + ], + ); + + const discardPendingAttachments = useCallback(() => { + const pending = attachmentsRef.current; + attachmentsRef.current = []; + setAttachments([]); + for (const attachment of pending) { + void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined); + } + }, []); + + const submit = useCallback( + (value: string) => { + const text = value.trim(); + if (!text || isRequesting) return; + const submittedAttachments = attachmentsRef.current; + attachmentsRef.current = []; + setAttachments([]); + setInput(''); + const params: AiChatInput = { + message: text, + attachmentIds: submittedAttachments.map((item) => item.id), + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + localAttachments: submittedAttachments, + }; + if (activeId != null) { + requestWithStatus(params); + return; + } + // 草稿态:先创建 session,再发送第一条消息 + void (async () => { + try { + const created = toConversationData(await aiChatApi.createConversation()); + addConversation(created, 'prepend'); + pendingDraftConversationIdRef.current = created.id; + markConversationRunning(created.id); + // 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出, + // 保证消息写入新会话的 store,界面能正常显示对话内容。 + queueRequest(created.key, params); + setActiveConversationKey(created.key); + } catch { + message.error('创建会话失败,请重试'); + attachmentsRef.current = submittedAttachments; + setAttachments(submittedAttachments); + setInput(text); + } + })(); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + addConversation, + deepThinking, + isRequesting, + markConversationRunning, + queueRequest, + requestWithStatus, + setActiveConversationKey, + ], + ); + + // 草稿 session 创建完成、provider 就绪后注册中止句柄 + useEffect(() => { + if (activeId == null || !provider) return; + if (activeId !== pendingDraftConversationIdRef.current) return; + pendingDraftConversationIdRef.current = null; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + }, [activeId, provider, requestAbortRef]); + + const reloadMessage = useCallback( + (messageInfo: MessageInfo) => { + reloadWithStatus(messageInfo); + }, + [reloadWithStatus], + ); + + const copyMessage = useCallback((message: AiChatMessage) => { + if (!message.content) return; + void navigator.clipboard.writeText(message.content); + }, []); + + const confirmDeleteMessage = useCallback( + (messageInfo: MessageInfo) => { + if (!activeId || isRequesting) return; + const messageId = resolveUserMessageId(messageInfo, messagesRef.current); + if (messageId == null) return; + const scopeLabel = + messageInfo.message.role === 'user' ? '这条消息及其 AI 回答' : '这条 AI 回答'; + modal.confirm({ + title: '删除消息', + content: `将删除${scopeLabel},此操作不可恢复。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + const result = await aiChatApi.deleteMessage(activeId, messageId); + const storeIds = new Map(); + for (const item of messagesRef.current) { + if (typeof item.message.id === 'number') { + storeIds.set(item.message.id, item.id); + } + } + // 当前会话内新发送的用户消息没有服务端 ID,但可映射到本地 msg_N key + storeIds.set(messageId, messageInfo.id); + for (const id of result.deletedIds) removeMessage(storeIds.get(id) ?? id); + void refreshConversations(); + } catch (error) { + console.error('删除消息失败', error); + message.error('删除消息失败'); + } + }, + }); + }, + [activeId, isRequesting, refreshConversations, removeMessage], + ); + + const confirmEditMessage = useCallback( + (messageInfo: MessageInfo, value: string) => { + if (!activeId) return; + const content = value.trim(); + if (!content) { + message.warning('消息内容不能为空'); + return; + } + const messageId = resolveUserMessageId(messageInfo, messagesRef.current); + if (messageId == null) { + message.warning('消息尚未同步,请稍后重试'); + return; + } + setEditingMessageId(null); + if (content === messageInfo.message.content) return; + + setMessage(messageInfo.id, (info) => ({ + message: { + ...info.message, + content, + metadata: { ...info.message.metadata, edited: true }, + }, + })); + const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id); + if (index >= 0) { + for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + } + requestWithStatus({ + message: content, + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + editMessageId: messageId, + }); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + deepThinking, + removeMessage, + requestWithStatus, + setMessage, + ], + ); + + const submitForm = useCallback( + (form: AiFormSchema, values: Record) => { + if (!activeId || isRequesting) return; + requestWithStatus({ + message: '表单提交', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + formSubmission: { formId: form.id, values, formTitle: form.title }, + }); + }, + [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], + ); + + const submitReview = useCallback( + (reviewId: string, reviewTitle?: string) => { + if (!activeId || isRequesting) return; + requestWithStatus({ + message: '确认批量导入', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + reviewSubmission: { reviewId, reviewTitle }, + }); + }, + [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], + ); + + const confirmReviewStep = useCallback( + async ( + messageId: number | undefined, + reviewId: string, + sectionKey: AiReviewSection['key'], + ): Promise => { + const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey); + const apply = (review: AiReviewSchema) => { + if (provider?.onExternalReview && typeof messageId === 'number') { + provider.onExternalReview(messageId, review); + } else if (typeof messageId === 'number') { + setMessage(messageId, (info) => { + const reviews = info.message.reviews ?? []; + const exists = reviews.some((item) => item.id === review.id); + return { + message: { + ...info.message, + reviews: exists + ? reviews.map((item) => (item.id === review.id ? review : item)) + : [...reviews, review], + }, + }; + }); + } + }; + apply(updated); + return updated; + }, + [provider, setMessage], + ); + + const confirmReviewGroup = useCallback( + async ( + messageId: number | undefined, + reviewId: string, + type: AiReviewSectionType, + ): Promise => { + const updated = await aiChatApi.confirmReviewGroup(reviewId, type); + if (provider?.onExternalReview && typeof messageId === 'number') { + provider.onExternalReview(messageId, updated); + } else if (typeof messageId === 'number') { + setMessage(messageId, (info) => { + const reviews = info.message.reviews ?? []; + const exists = reviews.some((item) => item.id === updated.id); + return { + message: { + ...info.message, + reviews: exists + ? reviews.map((item) => (item.id === updated.id ? updated : item)) + : [...reviews, updated], + }, + }; + }); + } + return updated; + }, + [provider, setMessage], + ); + + const customUpload = useCallback>(async (options) => { + const file = options.file as File; + if (attachmentsRef.current.length >= 5) { + const error = new Error('每条消息最多添加 5 个附件'); + options.onError?.(error); + message.warning(error.message); + return; + } + try { + const uploaded = await aiChatApi.uploadAttachment(file); + setAttachments((items) => [...items, uploaded]); + options.onSuccess?.(uploaded, file); + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error('附件上传失败')); + message.error('附件上传失败'); + } + }, []); + + const removeAttachment = useCallback(async (file: UploadFile) => { + const attachment = file.response; + if (!attachment) return true; + try { + await aiChatApi.deleteAttachment(attachment.id); + setAttachments((items) => items.filter((item) => item.id !== attachment.id)); + return true; + } catch { + message.error('删除附件失败'); + return false; + } + }, []); + + const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]); + const promptItems = useMemo( + () => + (lockedSkill ? [lockedSkill] : skills) + .flatMap((skill) => + skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example })), + ) + .slice(0, 5) + .map(({ skill, example }) => ({ + key: `${skill.key}-${example}`, + label: example, + description: skill.name, + })), + [lockedSkill, skills], + ); + + const bubbleItems = useMemo( + () => + messages.map((info) => ({ + key: info.id, + role: info.message.role === 'assistant' ? 'assistant' : 'user', + status: info.status, + content: info.message, + extra: + info.status !== 'loading' && info.status !== 'updating' && !isRequesting ? ( + info.message.role === 'user' ? ( + editingMessageId === info.id ? undefined : ( + , + onClick: () => copyMessage(info.message), + }, + { + key: 'edit', + title: '编辑', + icon: , + onClick: () => setEditingMessageId(info.id), + }, + { + key: 'delete', + title: '删除', + icon: , + danger: true, + onClick: () => void confirmDeleteMessage(info), + }, + ]} + /> + ) + ) : ( + , + onClick: () => copyMessage(info.message), + }, + { + key: 'reload', + title: '重新生成', + icon: , + onClick: () => reloadMessage(info), + }, + ]} + /> + ) + ) : undefined, + contentRender: (content: AiChatMessage) => ( + confirmEditMessage(info, value) : undefined + } + onEditCancel={content.role === 'user' ? () => setEditingMessageId(null) : undefined} + onSubmitForm={submitForm} + onSubmitReview={submitReview} + onConfirmReviewStep={confirmReviewStep} + onConfirmReviewGroup={confirmReviewGroup} + onOpenImportWizard={setImportWizardRunId} + /> + ), + })), + [ + copyMessage, + confirmDeleteMessage, + confirmEditMessage, + confirmReviewGroup, + confirmReviewStep, + editingMessageId, + isRequesting, + messages, + reloadMessage, + setImportWizardRunId, + submitForm, + submitReview, + ], + ); + + return { + input, + setInput, + attachments, + setAttachments, + editingMessageId, + setEditingMessageId, + deepThinking, + setDeepThinking, + isRequesting, + messages, + stopRequest, + submit, + reloadMessage, + copyMessage, + confirmDeleteMessage, + confirmEditMessage, + submitForm, + submitReview, + confirmReviewStep, + confirmReviewGroup, + customUpload, + removeAttachment, + discardPendingAttachments, + uploadItems, + promptItems, + bubbleItems, + }; +} diff --git a/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx new file mode 100644 index 0000000..cf802f4 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx @@ -0,0 +1,415 @@ +import React from 'react'; +import { + ApiOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + CloudServerOutlined, + ReloadOutlined, + RobotOutlined, + SafetyOutlined, + SaveOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { + Alert, + AutoComplete, + Button, + Card, + Descriptions, + Form, + Input, + InputNumber, + Select, + Space, + Switch, + Tag, + Typography, +} from 'antd'; +import type { AiProvider } from './helpers'; +import { + PROVIDER_OPTIONS, + PROVIDER_DEFAULTS, + formatDateTime, + sourceColor, + sourceLabel, +} from './helpers'; +import styles from './index.module.css'; + +export interface AiConfigData { + id: number; + provider: AiProvider; + baseUrl: string; + hasApiKey: boolean; + hasDatabaseKey: boolean; + maskedApiKey: string | null; + keySource: 'database' | 'environment' | 'none'; + defaultModel: string | null; + enabled: boolean; + supportsVision: boolean; + timeoutMs: number; + reasoningEffort: string | null; + verified: boolean; + lastTestedAt: string | null; + lastTestLatencyMs: number | null; + createdAt: string; + updatedAt: string; +} + +export interface TestResult { + success: boolean; + latencyMs: number | null; + modelCount: number | null; + modelAvailable: boolean; + testedAt: string; + message: string; +} + +export interface FormValues { + provider: AiProvider; + baseUrl: string; + apiKey: string; + defaultModel: string; + timeoutMs: number; + supportsVision: boolean; + reasoningEffort: string; +} + +export const ProviderStep: React.FC<{ + canWrite: boolean; + isFixedProvider: boolean; + config?: AiConfigData | null; + onProviderChange: (provider: AiProvider) => void; +}> = ({ canWrite, isFixedProvider, config, onProviderChange }) => { + return ( + 服务商配置} extra={}> + + + + + + + + + ); +}; + +export const KeyStep: React.FC<{ + canWrite: boolean; + config?: AiConfigData | null; + onClearKey: () => void; +}> = ({ canWrite, config, onClearKey }) => { + return ( + 密钥配置} extra={}> + + + + + {config && ( + + + {config.hasApiKey ? ( + {config.maskedApiKey || '••••'} + ) : ( + 未配置 + )} + + + {sourceLabel(config.keySource)} + {config.keySource === 'environment' && ( + + 由环境变量托管,需在服务器修改 + + )} + + {formatDateTime(config.updatedAt)} + + )} + + {config?.hasDatabaseKey && canWrite && ( +
+ +
+ )} + + {config?.keySource === 'environment' && !config.hasDatabaseKey && ( +
+ 密钥由环境变量提供,无法通过页面清除 +
+ )} + +
+ API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS + 保护,服务端日志不记录密钥。 +
+
+ 也可通过环境变量 AI_API_KEY 注入密钥,环境变量优先级高于数据库存储。 +
+
+ ); +}; + +export const ModelStep: React.FC<{ + canWrite: boolean; + config?: AiConfigData | null; + onFetchModels: () => void; + fetchingModels: boolean; + modelOptions: Array<{ value: string; label: string }>; +}> = ({ canWrite, config, onFetchModels, fetchingModels, modelOptions }) => { + return ( + 模型选择} extra={}> +
+ + {modelOptions.length > 0 && {modelOptions.length} 个可用模型} +
+ + + + option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false + } + /> + + + + + + + + - - - - - - - - - -
+ ); - - // Step 1: API Key case 1: - return ( - 密钥配置} - extra={} - > - - - - - {config && ( - - - {config.hasApiKey ? ( - {config.maskedApiKey || '••••'} - ) : ( - 未配置 - )} - - - {sourceLabel(config.keySource)} - {config.keySource === 'environment' && ( - - 由环境变量托管,需在服务器修改 - - )} - - - {formatDateTime(config.updatedAt)} - - - )} - - {config?.hasDatabaseKey && canWrite && ( -
- -
- )} - - {config?.keySource === 'environment' && !config.hasDatabaseKey && ( -
- 密钥由环境变量提供,无法通过页面清除 -
- )} - -
- API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS - 保护,服务端日志不记录密钥。 -
-
- 也可通过环境变量 AI_API_KEY 注入密钥, - 环境变量优先级高于数据库存储。 -
-
- ); - - // Step 2: Model selection + return ; case 2: return ( - 模型选择} - extra={} - > -
- - {modelOptions.length > 0 && ( - {modelOptions.length} 个可用模型 - )} -
- - - - option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false - } - /> - - - - - - - - + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx new file mode 100644 index 0000000..ed45f3f --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx @@ -0,0 +1,260 @@ +import React, { useState } from 'react'; +import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined } from '@ant-design/icons'; +import api from '../../api'; +import { message } from '../../ui/app-message'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { usePermission } from '../../hooks/usePermission'; +import PermissionButton from '../PermissionButton'; +import { EditableArchiveCell } from './EditableArchiveCell'; +import { EXAM_TYPE_OPTIONS, formatEnrollmentDisplayName, getClassTypeLabel } from './shared'; +import type { EnrollmentRecord, ExamScoreRecord, TabProps } from './shared'; + +const EXAM_SCORE_FIELDS = { + examType: 'examType', + examName: 'examName', + subject: 'subject', + score: 'score', + classAvg: 'classAvg', + rank: 'rank', + examDate: 'examDate', + enrollmentId: 'enrollmentId', +} as const; + +export const ExamScoresTab: React.FC< + TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } +> = ({ data, studentId, enrollments }) => { + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeArchive = hasPermission('archive:purge'); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + const addExamScoreMutation = useApiMutation( + async (payload: Record) => + api.post(`/archive/${studentId}/exam-scores`, payload), + { invalidate: [['archive', studentId]] }, + ); + const saveExamScoreCellMutation = useApiMutation( + async ({ id, field, value }: { id: number; field: string; value: unknown }) => + api.put(`/archive/exam-scores/${id}`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const purgeExamScoreMutation = useApiMutation( + async (id: number) => api.delete(`/archive/exam-scores/${id}/permanent`), + { invalidate: [['archive', studentId]] }, + ); + + const handleAdd = async () => { + try { + const values = await form.validateFields(); + setSaving(true); + await addExamScoreMutation.mutateAsync({ + ...values, + examDate: values.examDate?.format('YYYY-MM-DD'), + }); + message.success('考试成绩已添加'); + setModalOpen(false); + form.resetFields(); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); + } + }; + + const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => { + try { + await saveExamScoreCellMutation.mutateAsync({ id: record.id, field, value }); + message.success('考试成绩已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (record: ExamScoreRecord) => { + modal.confirm({ + title: `永久删除考试成绩(${record.examName || record.subject || `记录${record.id}`})?`, + content: '删除后不可恢复,成绩记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeExamScoreMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const columns: ColumnsType = [ + { + title: '考试类型', + dataIndex: 'examType', + render: (v: string, r) => ( + + {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} + + ), + }, + { + title: '考试名称', + dataIndex: 'examName', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string, r) => ( + + {v} + + ), + }, + { + title: '成绩', + dataIndex: 'score', + render: (v: number | null, r) => ( + + {v ?? '-'} + + ), + }, + { + title: '班级均分', + dataIndex: 'classAvg', + render: (v: number | undefined, r) => ( + + {v !== undefined ? v : '-'} + + ), + }, + { + title: '排名', + dataIndex: 'rank', + render: (v: number | undefined, r) => ( + + {v !== undefined ? v : '-'} + + ), + }, + { + title: '考试日期', + dataIndex: 'examDate', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '关联报读', + dataIndex: 'enrollmentId', + render: (v: number | undefined, r) => ( + ({ value: item.id, label: formatEnrollmentDisplayName(item), }))} onSave={saveCell}> + {(() => { + if (r.examId) return r.exam?.class?.name || '-'; + if (v === undefined) return '-'; + const enr = enrollments.find((e) => e.id === v); + return enr ? formatEnrollmentDisplayName(enr) : String(v); + })()} + + ), + }, + { + title: '操作', + render: (_: unknown, r: ExamScoreRecord) => + r.status === 'archived' && canPurgeArchive ? ( + + ) : null, + }, + ]; + + return ( +
+ } + type="primary" + onClick={() => { + form.resetFields(); + setModalOpen(true); + }} + style={{ marginBottom: 16 }} + > + 添加考试成绩 + + + columns={columns} + dataSource={data} + rowKey="id" + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} + /> + setModalOpen(false)} + confirmLoading={saving} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 991f721..549e421 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -1,20 +1,12 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Tabs, Card, Descriptions, Table, Button, - Modal, - Form, - Input, - Select, - DatePicker, - InputNumber, - Upload, Tag, Space, - Popconfirm, Empty, Row, Col, @@ -23,9 +15,6 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { - PlusOutlined, - UploadOutlined, - InboxOutlined, EyeOutlined, CloseOutlined, FileTextOutlined, @@ -36,223 +25,42 @@ import api from '../../api'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { useViewSensitive } from '../../hooks/useViewSensitive'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; -import PermissionButton from '../PermissionButton'; +import { getErrorMessage } from '../../utils/error'; -// ---- Types ---- +import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared'; +import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; +import { EnrollmentsTab } from './EnrollmentsTab'; +import { ExamScoresTab } from './ExamScoresTab'; +import { LearningTab } from './LearningTab'; +import { AttachmentsTab } from './AttachmentsTab'; -interface StudentInfo { - id: number; - name: string; - phone: string; - idNumber: string; - studentNo: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organizationId?: number; - organization?: { id?: number; name?: string } | null; - supervisor?: string; - status: string; -} - -interface ProfileData { - targetCollege?: string; - targetMajor?: string; - collegeSchool?: string; - collegeMajor?: string; - subjectDirection?: string; - grade?: string; - profileDate?: string; - notes?: string; -} - -interface EnrollmentRecord { - id: number; - courseCategory: string; - classType: string; - className?: string; - headTeacher?: string; - subjectTeacher?: string; - startDate?: string; - endDate?: string; - status: string; -} - -interface ExamScoreRecord { - id: number; - examId?: number; - exam?: { class?: { name?: string } }; - examType: string; - examName?: string; - subject: string; - score: number | null; - classAvg?: number; - rank?: number; - examDate?: string; - enrollmentId?: number; -} - -interface LearningRecord { - id: number; - recordDate: string; - recordType: string; - content: string; - followUpMethod?: string; - nextStep?: string; -} - -interface ResultData { - cultureFinalScore?: number; - professionalFinalScore?: number; - admissionStatus?: string; - admittedCollege?: string; - admittedMajor?: string; -} - -interface AttachmentRecord { - id: number; - category: string; - fileName: string; - fileSize: number; -} - -interface AttendanceRecordItem { - id: number; - attendanceDate: string; - session: string; - status: string; - source?: string; - remark?: string | null; - punchTime?: string | null; - punchDeviceName?: string | null; - punchDeviceId?: string | null; - schedule?: { subject?: string } | null; - class?: { name?: string } | null; -} - -interface StudentProfileAggregate { - student: StudentInfo; - profile: ProfileData | null; - enrollments: EnrollmentRecord[]; - examScores: ExamScoreRecord[]; - learningRecords: LearningRecord[]; - result: ResultData | null; - attachments: AttachmentRecord[]; - attendances: AttendanceRecordItem[]; -} - -export interface StudentProfileContentProps { - studentId: number; - inDrawer?: boolean; - onClose?: () => void; -} - -// ---- Constants ---- - -const ADMISSION_STATUS_MAP: Record = { - admitted: { text: '已录取', color: 'green' }, - pending: { text: '待录取', color: 'orange' }, - rejected: { text: '未录取', color: 'red' }, - withdrawn: { text: '放弃', color: '#999' }, -}; - -const EXAM_TYPE_OPTIONS = [ - { value: 'monthly', label: '月考' }, - { value: 'midterm', label: '期中' }, - { value: 'final', label: '期末' }, - { value: 'mock', label: '模拟考' }, - { value: 'entrance', label: '入学测试' }, - { value: 'other', label: '其他' }, -]; - -const RECORD_TYPE_OPTIONS = [ - { value: 'study_feedback', label: '学习反馈' }, - { value: 'parent_communication', label: '家长沟通' }, - { value: 'behavior_note', label: '行为记录' }, - { value: 'meeting', label: '会议记录' }, - { value: 'other', label: '其他' }, -]; - -const ENROLLMENT_STATUS_MAP: Record = { - active: { text: '报读中', color: 'green' }, - completed: { text: '已结课', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, -}; - -const COURSE_CATEGORY_OPTIONS = [ - { value: 'culture', label: '文化课' }, - { value: 'professional', label: '专业课' }, - { value: 'comprehensive', label: '综合' }, -]; - -const CLASS_TYPE_OPTIONS = [ - { value: 'one_on_one', label: '一对一' }, - { value: 'small_group', label: '小班' }, - { value: 'large_class', label: '大班' }, - { value: 'online', label: '线上' }, - { value: 'offline', label: '线下' }, -]; - -const getOptionLabel = ( - options: Array<{ value: string; label: string }>, - value?: string | null, -): string => { - if (!value) return '-'; - return options.find((option) => option.value === value)?.label || value; -}; - -const getCourseCategoryLabel = (value?: string | null): string => - getOptionLabel(COURSE_CATEGORY_OPTIONS, value); - -const getClassTypeLabel = (value?: string | null): string => - getOptionLabel(CLASS_TYPE_OPTIONS, value); - -const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { - if (!value) return { text: '-', color: 'default' }; - return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; -}; - -const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => - enrollment.className || - (enrollment.courseCategory - ? getCourseCategoryLabel(enrollment.courseCategory) - : String(enrollment.id)); - -const ATTACHMENT_CATEGORY_OPTIONS = [ - { value: 'id_card', label: '身份证' }, - { value: 'transcript', label: '成绩单' }, - { value: 'certificate', label: '证书' }, - { value: 'contract', label: '合同' }, - { value: 'photo', label: '照片' }, - { value: 'other', label: '其他' }, -]; - -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - -// ---- Tab Components ---- - -const ATTENDANCE_STATUS_MAP: Record = { - present: { text: '出勤', color: 'green' }, - late: { text: '迟到', color: 'orange' }, - absent: { text: '缺勤', color: 'red' }, - leave: { text: '请假', color: 'blue' }, - pending: { text: '待确认', color: 'default' }, -}; - -const SESSION_LABELS: Record = { - morning_reading: '早自习', - morning: '上午', - afternoon: '下午', - evening_study: '晚自习', - night_check: '晚寝', -}; +const EditableField: React.FC<{ + value: unknown; + onSave: (value: unknown) => Promise | void; + editor?: React.ComponentProps['editor']; + min?: number; + required?: boolean; + children?: React.ReactNode; +}> = ({ value, onSave, editor, min, required, children }) => ( + { + await onSave(next); + }} + > + {children ?? String(value ?? '-')} + +); const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { const columns: ColumnsType = [ @@ -307,11 +115,6 @@ const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => ); }; -interface TabProps { - studentId: number; - onRefresh: () => void; -} - const InlineArchiveSummary: React.FC<{ studentId: number; student: StudentInfo; @@ -328,27 +131,51 @@ const InlineArchiveSummary: React.FC<{ profile, result, organizations, - onRefresh, onViewSensitive, canViewSensitive, canChooseOrganization, }) => { + const saveStudentMutation = useApiMutation( + async ({ field, value }: { field: keyof StudentInfo; value: unknown }) => + api.put(`/students/${studentId}`, { [field]: value }), + { invalidate: [['archive', studentId], ['students']] }, + ); + const saveProfileMutation = useApiMutation( + async ({ field, value }: { field: keyof ProfileData; value: unknown }) => + api.put(`/archive/${studentId}/profile`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const saveResultMutation = useApiMutation( + async ({ field, value }: { field: keyof ResultData; value: unknown }) => + api.put(`/archive/${studentId}/result`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const saveStudent = async (field: keyof StudentInfo, value: unknown) => { - await api.put(`/students/${studentId}`, { [field]: value }); - message.success('学生资料已保存'); - onRefresh(); + try { + await saveStudentMutation.mutateAsync({ field, value }); + message.success('学生资料已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const saveProfile = async (field: keyof ProfileData, value: unknown) => { - await api.put(`/archive/${studentId}/profile`, { [field]: value }); - message.success('档案已保存'); - onRefresh(); + try { + await saveProfileMutation.mutateAsync({ field, value }); + message.success('档案已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const saveResult = async (field: keyof ResultData, value: unknown) => { - await api.put(`/archive/${studentId}/result`, { [field]: value }); - message.success('录取信息已保存'); - onRefresh(); + try { + await saveResultMutation.mutateAsync({ field, value }); + message.success('录取信息已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const admissionStatus = getOptionLabel( @@ -381,34 +208,25 @@ const InlineArchiveSummary: React.FC<{ )} + - saveStudent('name', next)} - > + saveStudent('name', next)}> {student.name || '-'} - + + - saveStudent('studentNo', next)} - > + saveStudent('studentNo', next)}> {student.studentNo || '-'} - + + - saveStudent('gender', next)} - > + saveStudent('gender', next)}> {student.gender || '-'} - + + + - saveStudent('ethnicity', next)} - > + saveStudent('ethnicity', next)}> {student.ethnicity || '-'} - + + + + {canChooseOrganization ? ( + + + + + + + + + + + + + + = ({ - data, - studentId, - onRefresh, -}) => { - const { hasPermission } = usePermission(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleAdd = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.post(`/archive/${studentId}/enrollments`, { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }); - message.success('报读记录已添加'); - setModalOpen(false); - form.resetFields(); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => { - await api.put(`/archive/enrollments/${record.id}`, { [field]: value }); - message.success('报读记录已保存'); - onRefresh(); - }; - - const columns: ColumnsType = [ - { - title: '课程类别', - dataIndex: 'courseCategory', - render: (v: string, r) => ( - saveCell(r, 'courseCategory', next)} - > - {getCourseCategoryLabel(v)} - - ), - }, - { - title: '班型', - dataIndex: 'classType', - render: (v: string, r) => ( - saveCell(r, 'classType', next)} - > - {getClassTypeLabel(v)} - - ), - }, - { - title: '班级名称', - dataIndex: 'className', - render: (v: string, r) => ( - saveCell(r, 'className', next)} - > - {v || '-'} - - ), - }, - { - title: '班主任', - dataIndex: 'headTeacher', - render: (v: string, r) => ( - saveCell(r, 'headTeacher', next)} - > - {v || '-'} - - ), - }, - { - title: '任课教师', - dataIndex: 'subjectTeacher', - render: (v: string, r) => ( - saveCell(r, 'subjectTeacher', next)} - > - {v || '-'} - - ), - }, - { - title: '开始日期', - dataIndex: 'startDate', - render: (v: string, r) => ( - saveCell(r, 'startDate', next)} - > - {v || '-'} - - ), - }, - { - title: '结束日期', - dataIndex: 'endDate', - render: (v: string, r) => ( - saveCell(r, 'endDate', next)} - > - {v || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - render: (v: string, r) => { - const status = getEnrollmentStatus(v); - return ( - ({ - value, - label: item.text, - }))} - permission="student:edit" - onSave={(next) => saveCell(r, 'status', next)} - > - {status.text} - - ); - }, - }, - ]; - - return ( -
- } - type="primary" - onClick={() => { - form.resetFields(); - setModalOpen(true); - }} - style={{ marginBottom: 16 }} - > - 添加报读记录 - - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - /> - setModalOpen(false)} - confirmLoading={saving} - > -
- - - - - - - - - - - - - - - - - - -
-
-
- ); -}; - -const ExamScoresTab: React.FC< - TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } -> = ({ data, studentId, enrollments, onRefresh }) => { - const { hasPermission } = usePermission(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleAdd = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.post(`/archive/${studentId}/exam-scores`, { - ...values, - examDate: values.examDate?.format('YYYY-MM-DD'), - }); - message.success('考试成绩已添加'); - setModalOpen(false); - form.resetFields(); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => { - await api.put(`/archive/exam-scores/${record.id}`, { [field]: value }); - message.success('考试成绩已保存'); - onRefresh(); - }; - - const columns: ColumnsType = [ - { - title: '考试类型', - dataIndex: 'examType', - render: (v: string, r) => ( - saveCell(r, 'examType', next)} - > - {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} - - ), - }, - { - title: '考试名称', - dataIndex: 'examName', - render: (v: string, r) => ( - saveCell(r, 'examName', next)} - > - {v || '-'} - - ), - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string, r) => ( - saveCell(r, 'subject', next)} - > - {v} - - ), - }, - { - title: '成绩', - dataIndex: 'score', - render: (v: number | null, r) => ( - saveCell(r, 'score', next)} - > - {v ?? '-'} - - ), - }, - { - title: '班级均分', - dataIndex: 'classAvg', - render: (v: number | undefined, r) => ( - saveCell(r, 'classAvg', next)} - > - {v !== undefined ? v : '-'} - - ), - }, - { - title: '排名', - dataIndex: 'rank', - render: (v: number | undefined, r) => ( - saveCell(r, 'rank', next)} - > - {v !== undefined ? v : '-'} - - ), - }, - { - title: '考试日期', - dataIndex: 'examDate', - render: (v: string, r) => ( - saveCell(r, 'examDate', next)} - > - {v || '-'} - - ), - }, - { - title: '关联报读', - dataIndex: 'enrollmentId', - render: (v: number | undefined, r) => ( - ({ - value: item.id, - label: formatEnrollmentDisplayName(item), - }))} - permission="student:edit" - disabled={!!r.examId} - onSave={(next) => saveCell(r, 'enrollmentId', next)} - > - {(() => { - if (r.examId) return r.exam?.class?.name || '-'; - if (v === undefined) return '-'; - const enr = enrollments.find((e) => e.id === v); - return enr ? formatEnrollmentDisplayName(enr) : String(v); - })()} - - ), - }, - ]; - - return ( -
- } - type="primary" - onClick={() => { - form.resetFields(); - setModalOpen(true); - }} - style={{ marginBottom: 16 }} - > - 添加考试成绩 - - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - /> - setModalOpen(false)} - confirmLoading={saving} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- ); -}; - -const AttachmentsTab: React.FC = ({ - data, - studentId, - onRefresh, -}) => { - const { hasPermission } = usePermission(); - const [uploading, setUploading] = useState(false); - - const handleDelete = async (attachmentId: number) => { - try { - await api.delete(`/archive/attachments/${attachmentId}`); - message.success('已归档'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '归档失败'); - } - }; - - const columns: ColumnsType = [ - { - title: '类别', - dataIndex: 'category', - render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v, - }, - { title: '文件名', dataIndex: 'fileName' }, - { title: '大小', dataIndex: 'fileSize', render: formatFileSize }, - { - title: '操作', - render: (_: unknown, record: AttachmentRecord) => ( - - - {hasPermission('student:edit') ? ( - handleDelete(record.id)}> - - - ) : null} - - ), - }, - ]; - - return ( -
- {hasPermission('student:edit') ? ( - { - const formData = new FormData(); - formData.append( - 'file', - options.file instanceof File - ? options.file - : new File([options.file as Blob], 'attachment'), - ); - setUploading(true); - try { - await api.post(`/archive/${studentId}/attachments`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - message.success('上传成功'); - options.onSuccess?.({}); - onRefresh(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : '上传失败'; - message.error(msg); - options.onError?.(e instanceof Error ? e : new Error(msg)); - } finally { - setUploading(false); - } - }} - > - - - ) : null} - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - style={{ marginTop: 16 }} - /> -
- ); -}; - -// ---- Main Component ---- - const StudentProfileContent: React.FC = ({ studentId, inDrawer, @@ -1407,39 +473,44 @@ const StudentProfileContent: React.FC = ({ 'student:edit', ); const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); - const [aggregateData, setAggregateData] = useState(null); - const [organizations, setOrganizations] = useState>([]); - const [loading, setLoading] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const res = await api.get(`/archive/${studentId}`); - setAggregateData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败'); - } finally { - setLoading(false); - } - }, [studentId]); - - useEffect(() => { - void fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!canLoadOrganizations) { - setOrganizations([]); - return; - } - api - .get('/organizations/options') - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - }, [canLoadOrganizations]); + const { + data: aggregateData, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ['archive', studentId], + queryFn: async () => { + try { + return validateResponse( + studentProfileAggregateSchema, + await api.get(`/archive/${studentId}`), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败')); + return null; + } + }, + }); + const { data: organizations = [] } = useQuery< + Array<{ id: number; name: string; isHost?: boolean }> + >({ + queryKey: ['organizations', 'options'], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + return validateResponse>( + organizationOptionsSchema, + await api.get('/organizations/options'), + ); + } catch { + return []; + } + }, + }); + const loading = isLoading || isFetching; + const fetchData = useCallback(() => refetch(), [refetch]); const handlePreviewReport = useCallback(async () => { try { @@ -1449,16 +520,13 @@ const StudentProfileContent: React.FC = ({ w.document.write(html); w.document.close(); } - } catch { + } catch (e) { + console.error('加载报告失败', e); message.error('加载报告失败'); } }, [studentId]); - const handleViewSensitive = useViewSensitive( - studentId, - '学生档案', - hasPermission('log:create'), - ); + const handleViewSensitive = useViewSensitive(studentId, '学生档案', hasPermission('log:create')); const tabItems = useMemo(() => { if (!aggregateData) return []; @@ -1521,6 +589,7 @@ const StudentProfileContent: React.FC = ({ return (
+ {inDrawer && ( diff --git a/apps/admin/src/components/StudentProfileContent/shared.ts b/apps/admin/src/components/StudentProfileContent/shared.ts new file mode 100644 index 0000000..378c059 --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/shared.ts @@ -0,0 +1,216 @@ +export interface StudentInfo { + id: number; + name: string; + phone: string; + idNumber: string; + studentNo: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + organizationId?: number; + organization?: { id?: number; name?: string } | null; + supervisor?: string; + status: string; +} + +export interface ProfileData { + targetCollege?: string; + targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; + subjectDirection?: string; + grade?: string; + profileDate?: string; + notes?: string; +} + +export interface EnrollmentRecord { + id: number; + courseCategory: string; + classType: string; + className?: string; + headTeacher?: string; + subjectTeacher?: string; + startDate?: string; + endDate?: string; + status: string; +} + +export interface ExamScoreRecord { + id: number; + status?: string; + examId?: number; + exam?: { class?: { name?: string } }; + examType: string; + examName?: string; + subject: string; + score: number | null; + classAvg?: number; + rank?: number; + examDate?: string; + enrollmentId?: number; +} + +export interface LearningRecord { + id: number; + status?: string; + recordDate: string; + recordType: string; + content: string; + followUpMethod?: string; + nextStep?: string; +} + +export interface ResultData { + cultureFinalScore?: number; + professionalFinalScore?: number; + admissionStatus?: string; + admittedCollege?: string; + admittedMajor?: string; +} + +export interface AttachmentRecord { + id: number; + status?: string; + category: string; + fileName: string; + fileSize: number; +} + +export interface AttendanceRecordItem { + id: number; + attendanceDate: string; + session: string; + status: string; + source?: string; + remark?: string | null; + punchTime?: string | null; + punchDeviceName?: string | null; + punchDeviceId?: string | null; + schedule?: { subject?: string } | null; + class?: { name?: string } | null; +} + +export interface StudentProfileAggregate { + student: StudentInfo; + profile: ProfileData | null; + enrollments: EnrollmentRecord[]; + examScores: ExamScoreRecord[]; + learningRecords: LearningRecord[]; + result: ResultData | null; + attachments: AttachmentRecord[]; + attendances: AttendanceRecordItem[]; +} + +export interface StudentProfileContentProps { + studentId: number; + inDrawer?: boolean; + onClose?: () => void; +} + +export const ADMISSION_STATUS_MAP: Record = { + admitted: { text: '已录取', color: 'green' }, + pending: { text: '待录取', color: 'orange' }, + rejected: { text: '未录取', color: 'red' }, + withdrawn: { text: '放弃', color: '#999' }, +}; + +export const EXAM_TYPE_OPTIONS = [ + { value: 'monthly', label: '月考' }, + { value: 'midterm', label: '期中' }, + { value: 'final', label: '期末' }, + { value: 'mock', label: '模拟考' }, + { value: 'entrance', label: '入学测试' }, + { value: 'other', label: '其他' }, +]; + +export const RECORD_TYPE_OPTIONS = [ + { value: 'study_feedback', label: '学习反馈' }, + { value: 'parent_communication', label: '家长沟通' }, + { value: 'behavior_note', label: '行为记录' }, + { value: 'meeting', label: '会议记录' }, + { value: 'other', label: '其他' }, +]; + +export const ENROLLMENT_STATUS_MAP: Record = { + active: { text: '报读中', color: 'green' }, + completed: { text: '已结课', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const COURSE_CATEGORY_OPTIONS = [ + { value: 'culture', label: '文化课' }, + { value: 'professional', label: '专业课' }, + { value: 'comprehensive', label: '综合' }, +]; + +export const CLASS_TYPE_OPTIONS = [ + { value: 'one_on_one', label: '一对一' }, + { value: 'small_group', label: '小班' }, + { value: 'large_class', label: '大班' }, + { value: 'online', label: '线上' }, + { value: 'offline', label: '线下' }, +]; + +export const getOptionLabel = ( + options: Array<{ value: string; label: string }>, + value?: string | null, +): string => { + if (!value) return '-'; + return options.find((option) => option.value === value)?.label || value; +}; + +export const getCourseCategoryLabel = (value?: string | null): string => + getOptionLabel(COURSE_CATEGORY_OPTIONS, value); + +export const getClassTypeLabel = (value?: string | null): string => + getOptionLabel(CLASS_TYPE_OPTIONS, value); + +export const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +export const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => + enrollment.className || + (enrollment.courseCategory + ? getCourseCategoryLabel(enrollment.courseCategory) + : String(enrollment.id)); + +export const ATTACHMENT_CATEGORY_OPTIONS = [ + { value: 'id_card', label: '身份证' }, + { value: 'transcript', label: '成绩单' }, + { value: 'certificate', label: '证书' }, + { value: 'contract', label: '合同' }, + { value: 'photo', label: '照片' }, + { value: 'other', label: '其他' }, +]; + +export const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +export const ATTENDANCE_STATUS_MAP: Record = { + present: { text: '出勤', color: 'green' }, + late: { text: '迟到', color: 'orange' }, + absent: { text: '缺勤', color: 'red' }, + leave: { text: '请假', color: 'blue' }, + pending: { text: '待确认', color: 'default' }, +}; + +export const SESSION_LABELS: Record = { + morning_reading: '早自习', + morning: '上午', + afternoon: '下午', + evening_study: '晚自习', + night_check: '晚寝', +}; + +export interface TabProps { + studentId: number; + onRefresh: () => void; +} diff --git a/apps/admin/src/pages/StudentProfile/index.tsx b/apps/admin/src/pages/StudentProfile/index.tsx index 8d964d6..4bcde4b 100644 --- a/apps/admin/src/pages/StudentProfile/index.tsx +++ b/apps/admin/src/pages/StudentProfile/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { Card, Button, Space } from 'antd'; import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons'; import StudentProfileContent from '../../components/StudentProfileContent'; diff --git a/apps/admin/src/pages/archive-view.integration.test.ts b/apps/admin/src/pages/archive-view.integration.test.ts index 4d36a29..db613e4 100644 --- a/apps/admin/src/pages/archive-view.integration.test.ts +++ b/apps/admin/src/pages/archive-view.integration.test.ts @@ -35,22 +35,47 @@ describe('归档数据视图', () => { }); it('正常与归档视图的批量动作互斥,且归档视图只读', () => { - expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false }); - expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true }); + expect(archiveViewPolicy('active')).toEqual({ + batchAction: 'archive', + readonly: false, + purgeBatch: false, + }); + expect(archiveViewPolicy('archived')).toEqual({ + batchAction: 'restore', + readonly: true, + purgeBatch: true, + }); }); it('入住三态分别只提供退宿、归档和恢复动作', () => { expect(occupancyViewPolicy('active')).toEqual({ batchAction: 'checkout', readonly: false, + purgeBatch: false, + }); + expect(occupancyViewPolicy('all')).toEqual({ + batchAction: 'archive', + readonly: false, + purgeBatch: false, }); - expect(occupancyViewPolicy('all')).toEqual({ batchAction: 'archive', readonly: false }); expect(occupancyViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true, + purgeBatch: true, }); }); + it('批量删除只出现在归档视图,且与批量恢复互斥', () => { + expect(archiveViewPolicy('active').purgeBatch).toBe(false); + expect(archiveViewPolicy('archived').purgeBatch).toBe(true); + expect(occupancyViewPolicy('active').purgeBatch).toBe(false); + expect(occupancyViewPolicy('all').purgeBatch).toBe(false); + expect(occupancyViewPolicy('archived').purgeBatch).toBe(true); + // 归档视图中批量动作固定为恢复,不会同时出现归档;批量删除只在归档视图开启 + expect(archiveViewPolicy('archived').batchAction).toBe('restore'); + expect(occupancyViewPolicy('archived').batchAction).toBe('restore'); + }); + it('只有实际切换视图时才要求清空选择', () => { expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true); expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false); diff --git a/apps/admin/src/pages/archive-view.ts b/apps/admin/src/pages/archive-view.ts index 26c0ba0..cc86185 100644 --- a/apps/admin/src/pages/archive-view.ts +++ b/apps/admin/src/pages/archive-view.ts @@ -5,6 +5,8 @@ export type BatchAction = 'archive' | 'restore' | 'checkout'; export interface ViewPolicy { batchAction: BatchAction; readonly: boolean; + /** 批量永久删除只在已归档视图中出现,与批量恢复互斥 */ + purgeBatch: boolean; } export const selectArchiveRecords = ( @@ -20,11 +22,13 @@ export const expenseStatusForView = (view: ArchiveView) => view; export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({ batchAction: view === 'archived' ? 'restore' : 'archive', readonly: view === 'archived', + purgeBatch: view === 'archived', }); export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({ batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore', readonly: view === 'archived', + purgeBatch: view === 'archived', }); export const shouldClearSelectionOnViewChange = (current: T, next: T) => diff --git a/apps/server/src/archive/archive-report.attendance.ts b/apps/server/src/archive/archive-report.attendance.ts new file mode 100644 index 0000000..18292f2 --- /dev/null +++ b/apps/server/src/archive/archive-report.attendance.ts @@ -0,0 +1,165 @@ +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildAttendance(records: AttendanceRecord[], now: string): string { + const present = records.filter((r) => r.status === 'present').length; + const absent = records.filter((r) => r.status === 'absent').length; + const late = records.filter((r) => r.status === 'late').length; + const leave = records.filter((r) => r.status === 'leave').length; + const total = records.length; + const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0'; + + const metricHtml = ` +
+
+
${esc(now)} · 系统生成
+
出勤记录
+
+
+
+
+
总考勤次数
+ ${total} +

累计记录

+
+
+
出勤率
+ ${esc(rate)}% +

出勤: ${present} 次

+
+
+
缺勤 / 迟到
+ ${absent} / ${late} +

缺勤 ${absent} · 迟到 ${late}

+
+
+
请假
+ ${leave} +

累计请假次数

+
+
`; + + const chart = renderAttendanceBar(records); + const matrix = renderAttendanceMatrix(records); + + let extraHtml = ''; + if (records.length === 0) { + extraHtml = ''; + } + + return pageFrame(` + ${pageHeader('出勤记录')} + ${metricHtml} + ${extraHtml} + ${chart} + ${matrix} + ${pageFooter()} + `); +} + +export function renderAttendanceBar(records: AttendanceRecord[]): string { + if (records.length === 0) return ''; + + const statuses = ['present', 'absent', 'late', 'leave'] as const; + const counts = statuses.map((s) => records.filter((r) => r.status === s).length); + const labels = ['出勤', '缺勤', '迟到', '请假']; + const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75']; + const maxCount = Math.max(...counts, 1); + + const w = 600; + const h = 150; + const pad = { top: 20, right: 20, bottom: 30, left: 40 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + const barGap = 30; + const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length; + + const scaleH = (v: number): number => (v / maxCount) * plotH; + + let bars = ''; + for (let i = 0; i < statuses.length; i++) { + const x = pad.left + i * (barW + barGap); + const bh = scaleH(counts[i]); + const y = pad.top + plotH - bh; + bars += ``; + bars += `${counts[i]}`; + bars += `${labels[i]}`; + } + + // Y-axis grid + const ySteps = 4; + let yGrid = ''; + for (let i = 0; i <= ySteps; i++) { + const val = Math.round((maxCount * i) / ySteps); + const y = pad.top + plotH - (plotH * i) / ySteps; + yGrid += `${val}`; + if (i < ySteps) { + yGrid += ``; + } + } + + return `
+

出勤统计

+ + + ${yGrid} + ${bars} + +
`; +} + +export function renderAttendanceMatrix(records: AttendanceRecord[]): string { + if (records.length === 0) return ''; + + // Group by date + const dateMap = new Map(); + for (const r of records) { + const existing = dateMap.get(r.attendanceDate) ?? []; + existing.push(r); + dateMap.set(r.attendanceDate, existing); + } + + const dates = [...dateMap.keys()].sort(); + const sessions = ['上午', '下午', '晚自习']; + + let rows = ''; + for (const date of dates.slice(-30)) { + const dayRecords = dateMap.get(date) ?? []; + const cellMap = new Map(); + for (const r of dayRecords) { + cellMap.set(r.session, r.status); + } + + let cells = ''; + for (const session of sessions) { + const status = cellMap.get(session) ?? ''; + cells += `${status ? statusBadge(status) : '-'}`; + } + + rows += `${esc(date)}${cells}`; + } + + return `
+

考勤明细(最近30条)

+ + + + ${sessions.map((s) => ``).join('')} + + ${rows} +
日期${esc(s)}
+
图例: 出勤   缺勤   迟到   请假
+
`; +} + +export function statusBadge(status: string): string { + const map: Record = { + present: { cls: 'present', text: '到' }, + absent: { cls: 'absent', text: '缺' }, + late: { cls: 'late', text: '迟' }, + leave: { cls: 'leave', text: '假' }, + }; + const entry = map[status]; + if (!entry) return `${esc(status)}`; + return `${entry.text}`; +} diff --git a/apps/server/src/archive/archive-report.cover.ts b/apps/server/src/archive/archive-report.cover.ts new file mode 100644 index 0000000..7ed0618 --- /dev/null +++ b/apps/server/src/archive/archive-report.cover.ts @@ -0,0 +1,89 @@ +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { buildEnrollmentSection } from './archive-report.enrollment'; + +export function buildCover( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, +): string { + const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; + + return pageFrame(` + ${pageHeader('封面')} +
学生档案报告
+
生成日期: ${esc(now)}
+
+
+
${esc(student.name)}
+
学号: ${esc(student.studentNo || '-')}
身份证号: ${esc(student.idNumber || '-')}
+
+
+
+
科类方向
+
${esc(profile?.subjectDirection || '-')}
+
+
+
目标院校
+
${esc(profile?.targetCollege || '-')}
+
+
+
目标专业
+
${esc(profile?.targetMajor || '-')}
+
+
+
报读班型
+
${esc(types)}
+
+
+
+
+
01基础信息与报读记录第 2 页
+
02考试成绩总览第 3 页
+
03出勤记录第 4 页
+
04文化课考试成绩第 5 页
+
05学情记录与录取归档第 6 页
+
+
恭学教育
+ ${pageFooter()} + `); +} + +export function buildBasicInfo( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, +): string { + const infoCards = ` +
+
+
${esc(now)} · 系统生成
+
基础信息
+
+
+
+

个人信息

+
+
姓名${esc(student.name)}
+
性别${esc(student.gender || '-')}
+
电话${esc(student.phone || '-')}
+
民族${esc(student.ethnicity || '-')}
+
紧急联系人${esc(student.emergencyContact || '-')}
+
紧急电话${esc(student.emergencyPhone || '-')}
+
年级${esc(profile?.grade || '-')}
+
+
`; + + const enrollmentSection = buildEnrollmentSection(enrollments); + + return pageFrame(` + ${pageHeader('基础信息')} + ${infoCards} + ${enrollmentSection} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.enrollment.ts b/apps/server/src/archive/archive-report.enrollment.ts new file mode 100644 index 0000000..666e47a --- /dev/null +++ b/apps/server/src/archive/archive-report.enrollment.ts @@ -0,0 +1,80 @@ +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { esc } from './archive-report.helpers'; + +export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string { + if (enrollments.length === 0) { + return ``; + } + + const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { + if (enrs.length === 0) { + return ``; + } + + let rows = ''; + for (const e of enrs) { + rows += ` + ${esc(e.courseCategory || '-')} + ${esc(e.classType || '-')} + ${esc(e.className || '-')} + ${esc(e.headTeacher || '-')} + ${esc(e.subjectTeacher || '-')} + ${esc(e.startDate || '-')} + ${esc(e.endDate || '-')} + `; + } + + return ` + + + + + ${rows} +
课程类别班型班级班主任任课老师开班日期结课日期
`; + }; + + // Multi-enrollment: split culture vs professional + const cultureEnrollments = enrollments.filter( + (e) => e.courseCategory && e.courseCategory.includes('文化'), + ); + const profEnrollments = enrollments.filter( + (e) => e.courseCategory && e.courseCategory.includes('专业'), + ); + const otherEnrollments = enrollments.filter( + (e) => + !e.courseCategory || + (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), + ); + + if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { + let html = + '

报读记录

'; + html += '
'; + + html += '
'; + html += '

文化课报读

'; + html += renderEnrollmentTable(cultureEnrollments); + html += '
'; + + html += '
'; + html += '

专业课报读

'; + html += renderEnrollmentTable(profEnrollments); + html += '
'; + + html += '
'; + + if (otherEnrollments.length > 0) { + html += + '

其他报读

'; + html += renderEnrollmentTable(otherEnrollments); + } + + html += '
'; + return html; + } + + return `
+

报读记录

+ ${renderEnrollmentTable(enrollments)} +
`; +} diff --git a/apps/server/src/archive/archive-report.exam.ts b/apps/server/src/archive/archive-report.exam.ts new file mode 100644 index 0000000..e3df623 --- /dev/null +++ b/apps/server/src/archive/archive-report.exam.ts @@ -0,0 +1,249 @@ +import { ExamScore } from '../entities/exam-score.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildExamOverview(exams: ExamScore[], now: string): string { + const cultureExams = exams.filter( + (e) => e.examType && e.examType.includes('文化'), + ); + const entranceExam = exams.find((e) => e.examType === '入学测试'); + const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; + + const entranceScore = entranceExam?.score?.toFixed(1) ?? '-'; + const highestScore = highestExam?.score?.toFixed(1) ?? '-'; + const highestName = highestExam?.examName ?? '-'; + + // Improvement: last exam score minus first exam score + const sortedScores = cultureExams + .map((exam) => exam.score) + .filter((score): score is number => score !== null && score !== undefined); + let improvement = '—'; + if (sortedScores.length >= 2) { + const first = sortedScores[0]; + const last = sortedScores[sortedScores.length - 1]; + improvement = (last - first).toFixed(1); + } + + const avgScore = + cultureExams.length > 0 + ? ( + cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / + cultureExams.length + ).toFixed(1) + : '-'; + + const metricHtml = ` +
+
+
${esc(now)} · 系统生成
+
考试成绩总览
+
+
+
+
+
入学测试成绩
+ ${esc(entranceScore)} +

入学摸底测试

+
+
+
最高分
+ ${esc(highestScore)} +

${esc(highestName)}

+
+
+
进步幅度
+ ${esc(improvement)} +

首考 → 末考变化

+
+
+
平均分
+ ${esc(avgScore)} +

文化课考试均分

+
+
`; + + const scoreTable = renderScoreTable(cultureExams); + const trendChart = renderScoreTrendChart(cultureExams); + + let extraHtml = ''; + if (cultureExams.length === 0) { + extraHtml = ''; + } + + return pageFrame(` + ${pageHeader('考试成绩总览')} + ${metricHtml} + ${extraHtml} + ${scoreTable} + ${trendChart} + ${pageFooter()} + `); +} + +export function renderScoreTable(exams: ExamScore[]): string { + if (exams.length === 0) return ''; + + return `
+

文化课考试成绩

+ + + + + + + ${exams + .map( + (e) => + ` + + + + + + + + `, + ) + .join('')} + +
类型名称科目分数班均排名日期
${esc(e.examType || '-')}${esc(e.examName || '-')}${esc(e.subject || '-')}${e.score != null ? e.score : '-'}${e.classAvg != null ? e.classAvg : '-'}${e.rank != null ? e.rank : '-'}${esc(e.examDate || '-')}
+
`; +} + +export function renderScoreTrendChart(exams: ExamScore[]): string { + const cultureExams = exams.filter((e) => e.score != null); + if (cultureExams.length === 0) return ''; + + const scores = cultureExams.map((e) => Number(e.score)); + const labels = cultureExams.map((e) => { + const d = e.examDate || '-'; + return d.length > 7 ? d.slice(5) : d; + }); + + const w = 600; + const h = 180; + const pad = { top: 20, right: 20, bottom: 30, left: 40 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + + const minScore = Math.min(...scores); + const maxScore = Math.max(...scores); + const scoreRange = maxScore - minScore || 1; + + const scaleY = (s: number): number => + pad.top + plotH - ((s - minScore) / scoreRange) * plotH; + + let points = ''; + let lines = ''; + for (let i = 0; i < scores.length; i++) { + const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; + const y = scaleY(scores[i]); + points += ``; + if (i > 0) { + const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW; + const py = scaleY(scores[i - 1]); + lines += ``; + } + } + + // Y-axis labels + const ySteps = 4; + let yLabels = ''; + for (let i = 0; i <= ySteps; i++) { + const val = minScore + (scoreRange * i) / ySteps; + const y = scaleY(val); + yLabels += `${val.toFixed(0)}`; + if (i > 0) { + yLabels += ``; + } + } + + // X-axis labels + let xLabels = ''; + const labelStep = Math.max(1, Math.floor(labels.length / 6)); + for (let i = 0; i < labels.length; i += labelStep) { + const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; + xLabels += `${esc(labels[i])}`; + } + + return `
+

成绩趋势

+ + + ${yLabels} + ${xLabels} + ${lines} + ${points} + +
趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数
+
`; +} + +export function buildExamDetail(exams: ExamScore[], now: string): string { + const cultureExams = exams.filter( + (e) => e.examType && e.examType.includes('文化'), + ); + + if (cultureExams.length === 0) { + return pageFrame(` + ${pageHeader('文化课考试成绩')} +
+
+
${esc(now)} · 系统生成
+
文化课考试成绩
+
+
+ + ${pageFooter()} + `); + } + + // Group by subject + const subjectMap = new Map(); + for (const e of cultureExams) { + const subject = e.subject || '其他'; + const existing = subjectMap.get(subject) ?? []; + existing.push(e); + subjectMap.set(subject, existing); + } + + let subjectCards = ''; + for (const [subject, subExams] of subjectMap) { + const best = Math.max(...subExams.map((e) => e.score ?? 0)); + const avg = ( + subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length + ).toFixed(1); + + let rows = ''; + for (const e of subExams) { + rows += ` + ${esc(e.examName || '-')} + ${e.score != null ? e.score : '-'} + ${e.classAvg != null ? e.classAvg : '-'} + ${e.rank != null ? e.rank : '-'} + ${esc(e.examDate || '-')} + `; + } + + subjectCards += `
+

${esc(subject)} · 最佳 ${best} · 均分 ${esc(avg)}

+ + + + + ${rows} +
考试名称分数班均排名日期
+
`; + } + + return pageFrame(` + ${pageHeader('文化课考试成绩')} +
+
+
${esc(now)} · 系统生成
+
文化课考试成绩
+
+
+ ${subjectCards} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.helpers.ts b/apps/server/src/archive/archive-report.helpers.ts new file mode 100644 index 0000000..f2ea33f --- /dev/null +++ b/apps/server/src/archive/archive-report.helpers.ts @@ -0,0 +1,20 @@ +export function esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function pageFrame(inner: string): string { + return `
${inner}
`; +} + +export function pageHeader(title: string): string { + return `
恭学教育 · 学生档案${esc(title)}
`; +} + +export function pageFooter(): string { + return ``; +} diff --git a/apps/server/src/archive/archive-report.learning.ts b/apps/server/src/archive/archive-report.learning.ts new file mode 100644 index 0000000..2039e9d --- /dev/null +++ b/apps/server/src/archive/archive-report.learning.ts @@ -0,0 +1,85 @@ +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildLearningAndResult( + learnings: LearningRecord[], + result: ResultArchive | null, + now: string, +): string { + let learningHtml = ''; + if (learnings.length === 0) { + learningHtml = ` +
+
+
${esc(now)} · 系统生成
+
学情记录
+
+
+ `; + } else { + const latest = learnings.slice(0, 15); + let rows = ''; + for (const r of latest) { + rows += ` + ${esc(r.recordDate || '-')} + ${esc(r.recordType || '-')} + ${esc((r.content || '-').slice(0, 200))} + ${esc(r.followUpMethod || '-')} + `; + } + + learningHtml = ` +
+
+
${esc(now)} · 系统生成
+
学情记录
+
+
+
+

最近学情记录

+ + + + + + ${rows} +
日期类型内容跟进方式
+
`; + } + + let resultHtml = ''; + if (result) { + resultHtml = ` +
+
+
录取归档
+
+
+
+
+
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
+
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
+
录取状态${esc(result.admissionStatus || '-')}
+
录取院校${esc(result.admittedCollege || '-')}
+
录取专业${esc(result.admittedMajor || '-')}
+
+
+
录取归档信息为最终结果,如有疑问请联系教务处
`; + } else { + resultHtml = ` +
+
+
录取归档
+
+
+ `; + } + + return pageFrame(` + ${pageHeader('学情记录与录取归档')} + ${learningHtml} + ${resultHtml} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 312ca0a..6994219 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -8,6 +8,12 @@ import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Student } from '../entities/student.entity'; +import { ARCHIVE_REPORT_CSS } from './archive-report.styles'; +import { esc } from './archive-report.helpers'; +import { buildCover, buildBasicInfo } from './archive-report.cover'; +import { buildExamOverview, buildExamDetail } from './archive-report.exam'; +import { buildAttendance } from './archive-report.attendance'; +import { buildLearningAndResult } from './archive-report.learning'; interface ReportData { student: Student; @@ -45,7 +51,7 @@ export class ArchiveReportService { if (!student) throw new Error('学生不存在'); - const data: ReportData = { + return this.buildHtml({ student, profile, enrollments, @@ -53,166 +59,7 @@ export class ArchiveReportService { learnings, result, attendances, - }; - - return this.buildHtml(data); - } - - private css(): string { - return ` - @page { size: A4; margin: 0; } - * { box-sizing: border-box; } - body { - margin: 0; background: #eef3f8; color: #101828; - font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; - -webkit-print-color-adjust: exact; print-color-adjust: exact; - } - .page { - position: relative; width: 210mm; height: 297mm; - margin: 0 auto 18px; padding: 14mm 15mm 10mm; - overflow: hidden; background: #fff; page-break-after: always; - } - .frame { - position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; - } - .header { - position: relative; z-index: 1; display: flex; align-items: center; - height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; - } - .logo { - width: 24px; height: 24px; border-radius: 6px; - display: inline-flex; align-items: center; justify-content: center; - margin-right: 8px; color: #fff; background: #155aa8; - font-weight: 800; font-size: 11px; - } - .brand { font-size: 10px; font-weight: 700; } - .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } - .footer { - position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; - display: flex; justify-content: space-between; - border-top: 1px solid #cfe0f2; padding-top: 5px; - font-size: 10px; color: #667085; - } - h1, h2, h3, p { margin: 0; } - .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } - .source { font-size: 12px; color: #667085; padding-bottom: 2px; } - .title-row { - display: flex; align-items: flex-end; justify-content: space-between; - margin: 26px 0 17px; - } - .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } - .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } - .cover-main { - display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; - } - .cover-name-card { - min-height: 174px; border: 1px solid #cfe0f2; - border-left: 5px solid #155aa8; padding: 22px 24px; - } - .cover-name { - font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; - } - .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } - .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } - .cover-cell { - min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; - } - .label { font-size: 11px; color: #667085; margin-bottom: 8px; } - .value { font-size: 14px; line-height: 1.5; font-weight: 700; } - .toc { margin-top: 58px; } - .toc-row { - display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; - height: 47px; border-bottom: 1px solid #cfe0f2; - } - .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } - .toc-name { font-size: 14px; font-weight: 800; } - .toc-page { text-align: right; color: #667085; font-size: 12px; } - .watermark { - position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; - font-size: 56px; font-weight: 900; writing-mode: vertical-rl; - } - .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } - .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } - .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } - .card h3 { font-size: 16px; margin-bottom: 14px; } - .data-table { - width: 100%; border-collapse: collapse; table-layout: fixed; - } - .data-table th, .data-table td { - border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; - line-height: 1.55; vertical-align: top; text-align: left; - } - .data-table th { - background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; - } - .data-table td { overflow-wrap: anywhere; word-break: break-word; } - .data-table .nowrap { white-space: nowrap; } - .metric { - min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; - } - .metric .label { margin-bottom: 7px; } - .metric strong { - display: block; color: #155aa8; font-size: 27px; line-height: 1.16; - margin-bottom: 10px; - } - .metric p { - color: #667085; font-size: 12px; line-height: 1.45; - } - .summary-row { - display: grid; grid-template-columns: 92px 1fr; gap: 12px; - padding: 14px 0; border-bottom: 1px solid #d6e3f2; - font-size: 13px; line-height: 1.6; - } - .summary-row:last-child { border-bottom: 0; } - .summary-row strong { color: #155aa8; } - .note { - margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; - background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; - } - .banner-note { - margin-top: 12px; padding: 11px 16px; background: #eef5ff; - color: #173f6f; font-size: 12px; line-height: 1.7; - } - .line-chart { width: 100%; height: 180px; display: block; } - .bar-chart { width: 100%; height: 150px; display: block; } - .status { - display: inline-flex; align-items: center; justify-content: center; - width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; - color: #fff; font-size: 11px; font-weight: 800; - } - .present { background: #18a77d; } - .leave { background: #f15b75; } - .late { background: #f59e0b; } - .absent { background: #dc2626; } - .progress-row { - display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; - gap: 8px; margin: 10px 0; font-size: 12px; - } - .progress-track { - height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; - } - .progress-track i { - display: block; height: 100%; border-radius: 999px; - background: linear-gradient(90deg, #155aa8, #2e7df0); - } - .muted { color: #667085; } - @media print { - body { background: #fff; } - .page { margin: 0; box-shadow: none; } - } - `; - } - - private pageFrame(inner: string): string { - return `
${inner}
`; - } - - private pageHeader(title: string): string { - return `
恭学教育 · 学生档案${this.esc(title)}
`; - } - - private pageFooter(): string { - return ``; + }); } private buildHtml(data: ReportData): string { @@ -226,679 +73,15 @@ export class ArchiveReportService { return ` -学生档案报告 - ${this.esc(name)} - +学生档案报告 - ${esc(name)} + -${this.buildCover(student, profile, enrollments, now)} -${this.buildBasicInfo(student, profile, enrollments, now)} -${this.buildExamOverview(exams, now)} -${this.buildAttendance(attendances, now)} -${this.buildExamDetail(exams, now)} -${this.buildLearningAndResult(learnings, result, now)} +${buildCover(student, profile, enrollments, now)} +${buildBasicInfo(student, profile, enrollments, now)} +${buildExamOverview(exams, now)} +${buildAttendance(attendances, now)} +${buildExamDetail(exams, now)} +${buildLearningAndResult(learnings, result, now)} `; } - - private buildCover( - student: Student, - profile: StudentProfile | null, - enrollments: StudentEnrollment[], - now: string, - ): string { - const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; - - return this.pageFrame(` - ${this.pageHeader('封面')} -
学生档案报告
-
生成日期: ${this.esc(now)}
-
-
-
${this.esc(student.name)}
-
学号: ${this.esc(student.studentNo || '-')}
身份证号: ${this.esc(student.idNumber || '-')}
-
-
-
-
科类方向
-
${this.esc(profile?.subjectDirection || '-')}
-
-
-
目标院校
-
${this.esc(profile?.targetCollege || '-')}
-
-
-
目标专业
-
${this.esc(profile?.targetMajor || '-')}
-
-
-
报读班型
-
${this.esc(types)}
-
-
-
-
-
01基础信息与报读记录第 2 页
-
02考试成绩总览第 3 页
-
03出勤记录第 4 页
-
04文化课考试成绩第 5 页
-
05学情记录与录取归档第 6 页
-
-
恭学教育
- ${this.pageFooter()} - `); - } - - private buildBasicInfo( - student: Student, - profile: StudentProfile | null, - enrollments: StudentEnrollment[], - now: string, - ): string { - const infoCards = ` -
-
-
${this.esc(now)} · 系统生成
-
基础信息
-
-
-
-

个人信息

-
-
姓名${this.esc(student.name)}
-
性别${this.esc(student.gender || '-')}
-
电话${this.esc(student.phone || '-')}
-
民族${this.esc(student.ethnicity || '-')}
-
紧急联系人${this.esc(student.emergencyContact || '-')}
-
紧急电话${this.esc(student.emergencyPhone || '-')}
-
年级${this.esc(profile?.grade || '-')}
-
-
`; - - const enrollmentSection = this.buildEnrollmentSection(enrollments); - - return this.pageFrame(` - ${this.pageHeader('基础信息')} - ${infoCards} - ${enrollmentSection} - ${this.pageFooter()} - `); - } - - private buildEnrollmentSection(enrollments: StudentEnrollment[]): string { - if (enrollments.length === 0) { - return ``; - } - - const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { - if (enrs.length === 0) { - return ``; - } - - let rows = ''; - for (const e of enrs) { - rows += ` - ${this.esc(e.courseCategory || '-')} - ${this.esc(e.classType || '-')} - ${this.esc(e.className || '-')} - ${this.esc(e.headTeacher || '-')} - ${this.esc(e.subjectTeacher || '-')} - ${this.esc(e.startDate || '-')} - ${this.esc(e.endDate || '-')} - `; - } - - return ` - - - - - ${rows} -
课程类别班型班级班主任任课老师开班日期结课日期
`; - }; - - // Multi-enrollment: split culture vs professional - const cultureEnrollments = enrollments.filter( - (e) => e.courseCategory && e.courseCategory.includes('文化'), - ); - const profEnrollments = enrollments.filter( - (e) => e.courseCategory && e.courseCategory.includes('专业'), - ); - const otherEnrollments = enrollments.filter( - (e) => - !e.courseCategory || - (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), - ); - - if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { - let html = - '

报读记录

'; - html += '
'; - - html += '
'; - html += '

文化课报读

'; - html += renderEnrollmentTable(cultureEnrollments); - html += '
'; - - html += '
'; - html += '

专业课报读

'; - html += renderEnrollmentTable(profEnrollments); - html += '
'; - - html += '
'; - - if (otherEnrollments.length > 0) { - html += - '

其他报读

'; - html += renderEnrollmentTable(otherEnrollments); - } - - html += '
'; - return html; - } - - return `
-

报读记录

- ${renderEnrollmentTable(enrollments)} -
`; - } - - private buildExamOverview(exams: ExamScore[], now: string): string { - const cultureExams = exams.filter( - (e) => e.examType && e.examType.includes('文化'), - ); - const entranceExam = exams.find((e) => e.examType === '入学测试'); - const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; - - const entranceScore = entranceExam?.score?.toFixed(1) ?? '-'; - const highestScore = highestExam?.score?.toFixed(1) ?? '-'; - const highestName = highestExam?.examName ?? '-'; - - // Improvement: last exam score minus first exam score - const sortedScores = cultureExams - .map((exam) => exam.score) - .filter((score): score is number => score !== null && score !== undefined); - let improvement = '—'; - if (sortedScores.length >= 2) { - const first = sortedScores[0]; - const last = sortedScores[sortedScores.length - 1]; - improvement = (last - first).toFixed(1); - } - - const avgScore = - cultureExams.length > 0 - ? ( - cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / - cultureExams.length - ).toFixed(1) - : '-'; - - const metricHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
考试成绩总览
-
-
-
-
-
入学测试成绩
- ${this.esc(entranceScore)} -

入学摸底测试

-
-
-
最高分
- ${this.esc(highestScore)} -

${this.esc(highestName)}

-
-
-
进步幅度
- ${this.esc(improvement)} -

首考 → 末考变化

-
-
-
平均分
- ${this.esc(avgScore)} -

文化课考试均分

-
-
`; - - const scoreTable = this.renderScoreTable(cultureExams); - - const trendChart = this.renderScoreTrendChart(cultureExams); - - let extraHtml = ''; - if (cultureExams.length === 0) { - extraHtml = ''; - } - - return this.pageFrame(` - ${this.pageHeader('考试成绩总览')} - ${metricHtml} - ${extraHtml} - ${scoreTable} - ${trendChart} - ${this.pageFooter()} - `); - } - - private renderScoreTable(exams: ExamScore[]): string { - if (exams.length === 0) return ''; - - return `
-

文化课考试成绩

- - - - - - - ${exams - .map( - (e) => - ` - - - - - - - - `, - ) - .join('')} - -
类型名称科目分数班均排名日期
${this.esc(e.examType || '-')}${this.esc(e.examName || '-')}${this.esc(e.subject || '-')}${e.score != null ? e.score : '-'}${e.classAvg != null ? e.classAvg : '-'}${e.rank != null ? e.rank : '-'}${this.esc(e.examDate || '-')}
-
`; - } - - private renderScoreTrendChart(exams: ExamScore[]): string { - const cultureExams = exams.filter((e) => e.score != null); - if (cultureExams.length === 0) return ''; - - const scores = cultureExams.map((e) => Number(e.score)); - const labels = cultureExams.map((e) => { - const d = e.examDate || '-'; - return d.length > 7 ? d.slice(5) : d; - }); - - const w = 600; - const h = 180; - const pad = { top: 20, right: 20, bottom: 30, left: 40 }; - const plotW = w - pad.left - pad.right; - const plotH = h - pad.top - pad.bottom; - - const minScore = Math.min(...scores); - const maxScore = Math.max(...scores); - const scoreRange = maxScore - minScore || 1; - - const scaleY = (s: number): number => - pad.top + plotH - ((s - minScore) / scoreRange) * plotH; - - let points = ''; - let lines = ''; - for (let i = 0; i < scores.length; i++) { - const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; - const y = scaleY(scores[i]); - points += ``; - if (i > 0) { - const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW; - const py = scaleY(scores[i - 1]); - lines += ``; - } - } - - // Y-axis labels - const ySteps = 4; - let yLabels = ''; - for (let i = 0; i <= ySteps; i++) { - const val = minScore + (scoreRange * i) / ySteps; - const y = scaleY(val); - yLabels += `${val.toFixed(0)}`; - if (i > 0) { - yLabels += ``; - } - } - - // X-axis labels - let xLabels = ''; - const labelStep = Math.max(1, Math.floor(labels.length / 6)); - for (let i = 0; i < labels.length; i += labelStep) { - const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; - xLabels += `${this.esc(labels[i])}`; - } - - return `
-

成绩趋势

- - - ${yLabels} - ${xLabels} - ${lines} - ${points} - -
趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数
-
`; - } - - private buildAttendance(records: AttendanceRecord[], now: string): string { - const present = records.filter((r) => r.status === 'present').length; - const absent = records.filter((r) => r.status === 'absent').length; - const late = records.filter((r) => r.status === 'late').length; - const leave = records.filter((r) => r.status === 'leave').length; - const total = records.length; - const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0'; - - const metricHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
出勤记录
-
-
-
-
-
总考勤次数
- ${total} -

累计记录

-
-
-
出勤率
- ${this.esc(rate)}% -

出勤: ${present} 次

-
-
-
缺勤 / 迟到
- ${absent} / ${late} -

缺勤 ${absent} · 迟到 ${late}

-
-
-
请假
- ${leave} -

累计请假次数

-
-
`; - - const chart = this.renderAttendanceBar(records); - const matrix = this.renderAttendanceMatrix(records); - - let extraHtml = ''; - if (records.length === 0) { - extraHtml = ''; - } - - return this.pageFrame(` - ${this.pageHeader('出勤记录')} - ${metricHtml} - ${extraHtml} - ${chart} - ${matrix} - ${this.pageFooter()} - `); - } - - private renderAttendanceBar(records: AttendanceRecord[]): string { - if (records.length === 0) return ''; - - const statuses = ['present', 'absent', 'late', 'leave'] as const; - const counts = statuses.map((s) => records.filter((r) => r.status === s).length); - const labels = ['出勤', '缺勤', '迟到', '请假']; - const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75']; - const maxCount = Math.max(...counts, 1); - - const w = 600; - const h = 150; - const pad = { top: 20, right: 20, bottom: 30, left: 40 }; - const plotW = w - pad.left - pad.right; - const plotH = h - pad.top - pad.bottom; - const barGap = 30; - const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length; - - const scaleH = (v: number): number => (v / maxCount) * plotH; - - let bars = ''; - for (let i = 0; i < statuses.length; i++) { - const x = pad.left + i * (barW + barGap); - const bh = scaleH(counts[i]); - const y = pad.top + plotH - bh; - bars += ``; - bars += `${counts[i]}`; - bars += `${labels[i]}`; - } - - // Y-axis grid - const ySteps = 4; - let yGrid = ''; - for (let i = 0; i <= ySteps; i++) { - const val = Math.round((maxCount * i) / ySteps); - const y = pad.top + plotH - (plotH * i) / ySteps; - yGrid += `${val}`; - if (i < ySteps) { - yGrid += ``; - } - } - - return `
-

出勤统计

- - - ${yGrid} - ${bars} - -
`; - } - - private renderAttendanceMatrix(records: AttendanceRecord[]): string { - if (records.length === 0) return ''; - - // Group by date - const dateMap = new Map(); - for (const r of records) { - const existing = dateMap.get(r.attendanceDate) ?? []; - existing.push(r); - dateMap.set(r.attendanceDate, existing); - } - - const dates = [...dateMap.keys()].sort(); - const sessions = ['上午', '下午', '晚自习']; - - let rows = ''; - for (const date of dates.slice(-30)) { - const dayRecords = dateMap.get(date) ?? []; - const cellMap = new Map(); - for (const r of dayRecords) { - cellMap.set(r.session, r.status); - } - - let cells = ''; - for (const session of sessions) { - const status = cellMap.get(session) ?? ''; - cells += `${status ? this.statusBadge(status) : '-'}`; - } - - rows += `${this.esc(date)}${cells}`; - } - - return `
-

考勤明细(最近30条)

- - - - ${sessions.map((s) => ``).join('')} - - ${rows} -
日期${this.esc(s)}
-
图例: 出勤   缺勤   迟到   请假
-
`; - } - - private statusBadge(status: string): string { - const map: Record = { - present: { cls: 'present', text: '到' }, - absent: { cls: 'absent', text: '缺' }, - late: { cls: 'late', text: '迟' }, - leave: { cls: 'leave', text: '假' }, - }; - const entry = map[status]; - if (!entry) return `${this.esc(status)}`; - return `${entry.text}`; - } - - private buildExamDetail(exams: ExamScore[], now: string): string { - const cultureExams = exams.filter( - (e) => e.examType && e.examType.includes('文化'), - ); - - if (cultureExams.length === 0) { - return this.pageFrame(` - ${this.pageHeader('文化课考试成绩')} -
-
-
${this.esc(now)} · 系统生成
-
文化课考试成绩
-
-
- - ${this.pageFooter()} - `); - } - - // Group by subject - const subjectMap = new Map(); - for (const e of cultureExams) { - const subject = e.subject || '其他'; - const existing = subjectMap.get(subject) ?? []; - existing.push(e); - subjectMap.set(subject, existing); - } - - let subjectCards = ''; - for (const [subject, subExams] of subjectMap) { - const best = Math.max(...subExams.map((e) => e.score ?? 0)); - const avg = ( - subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length - ).toFixed(1); - - let rows = ''; - for (const e of subExams) { - rows += ` - ${this.esc(e.examName || '-')} - ${e.score != null ? e.score : '-'} - ${e.classAvg != null ? e.classAvg : '-'} - ${e.rank != null ? e.rank : '-'} - ${this.esc(e.examDate || '-')} - `; - } - - subjectCards += `
-

${this.esc(subject)} · 最佳 ${best} · 均分 ${this.esc(avg)}

- - - - - ${rows} -
考试名称分数班均排名日期
-
`; - } - - return this.pageFrame(` - ${this.pageHeader('文化课考试成绩')} -
-
-
${this.esc(now)} · 系统生成
-
文化课考试成绩
-
-
- ${subjectCards} - ${this.pageFooter()} - `); - } - - private buildLearningAndResult( - learnings: LearningRecord[], - result: ResultArchive | null, - now: string, - ): string { - let learningHtml = ''; - if (learnings.length === 0) { - learningHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
学情记录
-
-
- `; - } else { - const latest = learnings.slice(0, 15); - let rows = ''; - for (const r of latest) { - rows += ` - ${this.esc(r.recordDate || '-')} - ${this.esc(r.recordType || '-')} - ${this.esc((r.content || '-').slice(0, 200))} - ${this.esc(r.followUpMethod || '-')} - `; - } - - learningHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
学情记录
-
-
-
-

最近学情记录

- - - - - - ${rows} -
日期类型内容跟进方式
-
`; - } - - let resultHtml = ''; - if (result) { - resultHtml = ` -
-
-
录取归档
-
-
-
-
-
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
-
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
-
录取状态${this.esc(result.admissionStatus || '-')}
-
录取院校${this.esc(result.admittedCollege || '-')}
-
录取专业${this.esc(result.admittedMajor || '-')}
-
-
-
录取归档信息为最终结果,如有疑问请联系教务处
`; - } else { - resultHtml = ` -
-
-
录取归档
-
-
- `; - } - - return this.pageFrame(` - ${this.pageHeader('学情记录与录取归档')} - ${learningHtml} - ${resultHtml} - ${this.pageFooter()} - `); - } - - private esc(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } } diff --git a/apps/server/src/archive/archive-report.styles.ts b/apps/server/src/archive/archive-report.styles.ts new file mode 100644 index 0000000..fa21ea8 --- /dev/null +++ b/apps/server/src/archive/archive-report.styles.ts @@ -0,0 +1,142 @@ +export const ARCHIVE_REPORT_CSS = ` + @page { size: A4; margin: 0; } + * { box-sizing: border-box; } + body { + margin: 0; background: #eef3f8; color: #101828; + font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; + -webkit-print-color-adjust: exact; print-color-adjust: exact; + } + .page { + position: relative; width: 210mm; height: 297mm; + margin: 0 auto 18px; padding: 14mm 15mm 10mm; + overflow: hidden; background: #fff; page-break-after: always; + } + .frame { + position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; + } + .header { + position: relative; z-index: 1; display: flex; align-items: center; + height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; + } + .logo { + width: 24px; height: 24px; border-radius: 6px; + display: inline-flex; align-items: center; justify-content: center; + margin-right: 8px; color: #fff; background: #155aa8; + font-weight: 800; font-size: 11px; + } + .brand { font-size: 10px; font-weight: 700; } + .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } + .footer { + position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; + display: flex; justify-content: space-between; + border-top: 1px solid #cfe0f2; padding-top: 5px; + font-size: 10px; color: #667085; + } + h1, h2, h3, p { margin: 0; } + .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } + .source { font-size: 12px; color: #667085; padding-bottom: 2px; } + .title-row { + display: flex; align-items: flex-end; justify-content: space-between; + margin: 26px 0 17px; + } + .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } + .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-main { + display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; + } + .cover-name-card { + min-height: 174px; border: 1px solid #cfe0f2; + border-left: 5px solid #155aa8; padding: 22px 24px; + } + .cover-name { + font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; + } + .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .cover-cell { + min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; + } + .label { font-size: 11px; color: #667085; margin-bottom: 8px; } + .value { font-size: 14px; line-height: 1.5; font-weight: 700; } + .toc { margin-top: 58px; } + .toc-row { + display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; + height: 47px; border-bottom: 1px solid #cfe0f2; + } + .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } + .toc-name { font-size: 14px; font-weight: 800; } + .toc-page { text-align: right; color: #667085; font-size: 12px; } + .watermark { + position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; + font-size: 56px; font-weight: 900; writing-mode: vertical-rl; + } + .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } + .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } + .card h3 { font-size: 16px; margin-bottom: 14px; } + .data-table { + width: 100%; border-collapse: collapse; table-layout: fixed; + } + .data-table th, .data-table td { + border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; + line-height: 1.55; vertical-align: top; text-align: left; + } + .data-table th { + background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; + } + .data-table td { overflow-wrap: anywhere; word-break: break-word; } + .data-table .nowrap { white-space: nowrap; } + .metric { + min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; + } + .metric .label { margin-bottom: 7px; } + .metric strong { + display: block; color: #155aa8; font-size: 27px; line-height: 1.16; + margin-bottom: 10px; + } + .metric p { + color: #667085; font-size: 12px; line-height: 1.45; + } + .summary-row { + display: grid; grid-template-columns: 92px 1fr; gap: 12px; + padding: 14px 0; border-bottom: 1px solid #d6e3f2; + font-size: 13px; line-height: 1.6; + } + .summary-row:last-child { border-bottom: 0; } + .summary-row strong { color: #155aa8; } + .note { + margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; + background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; + } + .banner-note { + margin-top: 12px; padding: 11px 16px; background: #eef5ff; + color: #173f6f; font-size: 12px; line-height: 1.7; + } + .line-chart { width: 100%; height: 180px; display: block; } + .bar-chart { width: 100%; height: 150px; display: block; } + .status { + display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; + color: #fff; font-size: 11px; font-weight: 800; + } + .present { background: #18a77d; } + .leave { background: #f15b75; } + .late { background: #f59e0b; } + .absent { background: #dc2626; } + .progress-row { + display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; + gap: 8px; margin: 10px 0; font-size: 12px; + } + .progress-track { + height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; + } + .progress-track i { + display: block; height: 100%; border-radius: 999px; + background: linear-gradient(90deg, #155aa8, #2e7df0); + } + .muted { color: #667085; } + @media print { + body { background: #fff; } + .page { margin: 0; box-shadow: none; } + } + `; diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index fd9aeb4..f9afe1f 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -30,7 +30,7 @@ import { } from './dto/archive.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { withAuditLog } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; interface AuthenticatedRequest extends ExpressRequest { @@ -49,19 +49,9 @@ export class ArchiveController { @Get(':studentId') @RequirePermission('student:view') async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.getProfile(studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '查看档案', - targetId: studentId, - targetType: 'archive', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive', + }), () => this.archiveService.getProfile(studentId)); } @Put(':studentId/profile') @@ -71,20 +61,9 @@ export class ArchiveController { @Body() dto: UpsertProfileDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertProfile(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '更新档案信息', - targetId: studentId, - targetType: 'student_profile', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), + }), () => this.archiveService.upsertProfile(studentId, dto)); } @Post(':studentId/enrollments') @@ -94,20 +73,9 @@ export class ArchiveController { @Body() dto: CreateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addEnrollment(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加报名记录', - targetId: result.id, - targetType: 'student_enrollment', - detail: `${dto.courseCategory} - ${dto.classType}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`, + }), () => this.archiveService.addEnrollment(studentId, dto)); } @Put('enrollments/:id') @@ -117,38 +85,25 @@ export class ArchiveController { @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateEnrollment(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑报名记录', - targetId: id, - targetType: 'student_enrollment', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), + }), () => this.archiveService.updateEnrollment(id, dto)); } @Delete('enrollments/:id') @RequirePermission('student:edit') async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteEnrollment(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档报名记录', - targetId: id, - targetType: 'student_enrollment', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment', + }), () => this.archiveService.deleteEnrollment(id)); + } + + @Delete('enrollments/:id/permanent') + @RequirePermission('archive:purge') + async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeEnrollment(id)); } @Post(':studentId/exam-scores') @@ -158,20 +113,9 @@ export class ArchiveController { @Body() dto: CreateExamScoreDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addExamScore(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加考试成绩', - targetId: result.id, - targetType: 'exam_score', - detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, + }), () => this.archiveService.addExamScore(studentId, dto)); } @Put('exam-scores/:id') @@ -181,38 +125,25 @@ export class ArchiveController { @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateExamScore(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑考试成绩', - targetId: id, - targetType: 'exam_score', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), + }), () => this.archiveService.updateExamScore(id, dto)); } @Delete('exam-scores/:id') @RequirePermission('student:edit') async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteExamScore(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档考试成绩', - targetId: id, - targetType: 'exam_score', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score', + }), () => this.archiveService.deleteExamScore(id)); + } + + @Delete('exam-scores/:id/permanent') + @RequirePermission('archive:purge') + async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeExamScore(id)); } @Post(':studentId/learning-records') @@ -222,20 +153,9 @@ export class ArchiveController { @Body() dto: CreateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addLearningRecord(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加学习记录', - targetId: result.id, - targetType: 'learning_record', - detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, + }), () => this.archiveService.addLearningRecord(studentId, dto)); } @Put('learning-records/:id') @@ -245,38 +165,25 @@ export class ArchiveController { @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateLearningRecord(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑学习记录', - targetId: id, - targetType: 'learning_record', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), + }), () => this.archiveService.updateLearningRecord(id, dto)); } @Delete('learning-records/:id') @RequirePermission('student:edit') async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteLearningRecord(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档学习记录', - targetId: id, - targetType: 'learning_record', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record', + }), () => this.archiveService.deleteLearningRecord(id)); + } + + @Delete('learning-records/:id/permanent') + @RequirePermission('archive:purge') + async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeLearningRecord(id)); } @Put(':studentId/result') @@ -286,20 +193,9 @@ export class ArchiveController { @Body() dto: UpsertResultDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertResult(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '更新录取结果', - targetId: studentId, - targetType: 'result_archive', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), + }), () => this.archiveService.upsertResult(studentId, dto)); } @Post(':studentId/attachments') @@ -311,20 +207,9 @@ export class ArchiveController { @Body('category') category: string, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addAttachment(studentId, file, category || 'other'); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '上传附件', - targetId: result.id, - targetType: 'archive_attachment', - detail: `${file.originalname} (${category || 'other'})`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`, + }), () => this.archiveService.addAttachment(studentId, file, category || 'other')); } @Get(':studentId/attachments/:id') @@ -347,19 +232,17 @@ export class ArchiveController { @Delete('attachments/:id') @RequirePermission('student:edit') async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteAttachment(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档附件', - targetId: id, - targetType: 'archive_attachment', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment', + }), () => this.archiveService.deleteAttachment(id)); + } + + @Delete('attachments/:id/permanent') + @RequirePermission('archive:purge') + async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeAttachment(id)); } @Get(':studentId/report-html') @@ -368,18 +251,11 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'archive', - action: 'generate_report_html', - targetId: studentId, - targetType: 'student', - ipAddress, - userAgent, + return withAuditLog(this.logService, req, () => ({ + module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student', + }), async () => { + const html = await this.reportService.generateReportHtml(studentId); + return { html }; }); - const html = await this.reportService.generateReportHtml(studentId); - return { html }; } } diff --git a/apps/server/src/archive/archive.purge.controller.spec.ts b/apps/server/src/archive/archive.purge.controller.spec.ts new file mode 100644 index 0000000..81c64d7 --- /dev/null +++ b/apps/server/src/archive/archive.purge.controller.spec.ts @@ -0,0 +1,38 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ArchiveController } from './archive.controller'; + +describe('ArchiveController purge routes', () => { + it('requires archive:purge on permanent delete routes', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeEnrollment), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeExamScore), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeLearningRecord), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeAttachment), + ).toEqual(['archive:purge']); + }); + + it('writes permanent delete audit logs for sub-records', async () => { + const archiveService = { + purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ArchiveController( + archiveService as never, + { log } as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeEnrollment(1, req); + expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/archive/archive.purge.spec.ts b/apps/server/src/archive/archive.purge.spec.ts new file mode 100644 index 0000000..5f0952e --- /dev/null +++ b/apps/server/src/archive/archive.purge.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from '@nestjs/common'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ArchiveService } from './archive.service'; + +describe('ArchiveService purge sub-records', () => { + const createService = (overrides?: { + enrollment?: Record; + examScore?: Record; + learningRecord?: Record; + attachment?: Record; + scoreCount?: number; + }) => { + const enrollmentRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 1, + status: 'archived', + ...overrides?.enrollment, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const examScoreRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + status: 'archived', + ...overrides?.examScore, + }), + count: jest.fn().mockResolvedValue(overrides?.scoreCount ?? 0), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const learningRecordRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 3, + status: 'archived', + ...overrides?.learningRecord, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const attachmentRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 4, + status: 'archived', + filePath: 'x.pdf', + ...overrides?.attachment, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const service = new ArchiveService( + {} as never, + {} as never, + enrollmentRepo as never, + examScoreRepo as never, + learningRecordRepo as never, + {} as never, + attachmentRepo as never, + {} as never, + {} as never, + ); + return { service, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo }; + }; + + it('rejects non-archived sub-records', async () => { + const { service, enrollmentRepo } = createService({ enrollment: { status: 'active' } }); + await expect(service.purgeEnrollment(1)).rejects.toThrow( + new BadRequestException('仅已归档报名记录可以永久删除,请先归档'), + ); + expect(enrollmentRepo.delete).not.toHaveBeenCalled(); + }); + + it('rejects enrollments referenced by exam scores', async () => { + const { service, enrollmentRepo } = createService({ scoreCount: 1 }); + await expect(service.purgeEnrollment(1)).rejects.toThrow( + new BadRequestException('该报名记录已被考试成绩引用,无法永久删除'), + ); + expect(enrollmentRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes archived enrollment, exam score, and learning record', async () => { + const { service, enrollmentRepo, examScoreRepo, learningRecordRepo } = createService(); + await expect(service.purgeEnrollment(1)).resolves.toEqual({ + message: '已永久删除报名记录(不可恢复)', + }); + await expect(service.purgeExamScore(2)).resolves.toEqual({ + message: '已永久删除考试成绩(不可恢复)', + }); + await expect(service.purgeLearningRecord(3)).resolves.toEqual({ + message: '已永久删除学习记录(不可恢复)', + }); + expect(enrollmentRepo.delete).toHaveBeenCalledWith(1); + expect(examScoreRepo.delete).toHaveBeenCalledWith(2); + expect(learningRecordRepo.delete).toHaveBeenCalledWith(3); + }); + + it('deletes the attachment row and removes the disk file', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-purge-')); + process.env.UPLOAD_DIR = tmpDir; + const filePath = 'x.pdf'; + const fullPath = path.join(tmpDir, 'archive', filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, 'data'); + try { + const { service, attachmentRepo } = createService({ attachment: { filePath } }); + await expect(service.purgeAttachment(4)).resolves.toEqual({ + message: '已永久删除附件(不可恢复)', + }); + expect(fs.existsSync(fullPath)).toBe(false); + expect(attachmentRepo.delete).toHaveBeenCalledWith(4); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.UPLOAD_DIR; + } + }); +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index b6eb6cd..ac210b1 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -74,15 +74,15 @@ export class ArchiveService { attendances, ] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), - this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), + this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.examScoreRepo.find({ - where: { studentId, status: 'active' }, + where: { studentId }, relations: ['exam', 'exam.class'], order: { examDate: 'DESC' }, }), - this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }), + this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), - this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), + this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.attendanceRepo.find({ where: { studentId }, relations: ['schedule', 'class'], @@ -138,6 +138,20 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeEnrollment(id: number) { + const entity = await this.enrollmentRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('报名记录不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档报名记录可以永久删除,请先归档'); + } + const scoreCount = await this.examScoreRepo.count({ where: { enrollmentId: id } }); + if (scoreCount > 0) { + throw new BadRequestException('该报名记录已被考试成绩引用,无法永久删除'); + } + await this.enrollmentRepo.delete(id); + return { message: '已永久删除报名记录(不可恢复)' }; + } + private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) { if (enrollmentId === undefined) return; const enrollment = await this.enrollmentRepo.findOne({ @@ -173,6 +187,16 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeExamScore(id: number) { + const entity = await this.examScoreRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档考试成绩可以永久删除,请先归档'); + } + await this.examScoreRepo.delete(id); + return { message: '已永久删除考试成绩(不可恢复)' }; + } + async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); @@ -196,6 +220,16 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeLearningRecord(id: number) { + const entity = await this.learningRecordRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('学习记录不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档学习记录可以永久删除,请先归档'); + } + await this.learningRecordRepo.delete(id); + return { message: '已永久删除学习记录(不可恢复)' }; + } + async upsertResult(studentId: number, dto: UpsertResultDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); @@ -257,4 +291,23 @@ export class ArchiveService { await this.attachmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } + + async purgeAttachment(id: number) { + const entity = await this.attachmentRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('附件不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档附件可以永久删除,请先归档'); + } + if (entity.filePath) { + try { + const fullPath = this.resolveAttachmentPath(entity.filePath); + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } catch (error) { + // 磁盘文件删除失败仅告警,不阻塞数据库删除 + console.warn(`[ArchiveService] 附件文件删除失败: ${entity.filePath}`, error); + } + } + await this.attachmentRepo.delete(id); + return { message: '已永久删除附件(不可恢复)' }; + } } From e9c8a1085d8dbf67b94844b32539bf40a35bf8c0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:11:23 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20=E8=80=83=E5=8B=A4=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=87=8D=E6=9E=84=E4=B8=8E=E9=92=89=E9=92=89=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Attendance/AttendanceAdmin.helpers.tsx | 199 +++ .../Attendance/AttendanceAdminColumns.tsx | 131 ++ .../Attendance/AttendanceAdminHeader.tsx | 275 +++ .../Attendance/AttendanceAdminModals.tsx | 194 +++ .../Attendance/AttendanceAdminWorkspace.tsx | 164 ++ .../Attendance/LessonAttendanceDetail.tsx | 9 +- apps/admin/src/pages/Attendance/admin.tsx | 580 +++++++ apps/admin/src/pages/Attendance/index.tsx | 1381 +-------------- apps/admin/src/pages/Attendance/teacher.tsx | 218 +++ apps/admin/src/pages/AttendanceDevices.tsx | 101 +- .../attendance/attendance-calendar.service.ts | 118 ++ .../src/attendance/attendance-device.ts | 72 + .../src/attendance/attendance-dingtalk.ts | 101 ++ .../attendance-generation.service.ts | 299 ++++ .../attendance-import.controller.ts | 147 ++ .../attendance/attendance-import.service.ts | 5 - .../attendance/attendance-lesson.service.ts | 377 +++++ .../server/src/attendance/attendance-mutex.ts | 23 + .../attendance/attendance-query.service.ts | 319 ++++ .../attendance-record-mutation.service.ts | 156 ++ .../attendance-records.controller.ts | 365 ++++ .../attendance/attendance-report.service.ts | 196 +++ apps/server/src/attendance/attendance-time.ts | 43 + .../attendance/attendance.controller-base.ts | 57 + .../attendance/attendance.controller.spec.ts | 65 +- .../src/attendance/attendance.controller.ts | 591 +------ .../attendance.lesson-session.spec.ts | 161 +- .../src/attendance/attendance.module.ts | 4 +- .../src/attendance/attendance.service.spec.ts | 70 +- .../src/attendance/attendance.service.ts | 1499 ++--------------- .../src/integration/dingtalk.attendance.ts | 67 + .../server/src/integration/dingtalk.groups.ts | 183 ++ .../src/integration/dingtalk.schedules.ts | 94 ++ .../server/src/integration/dingtalk.shifts.ts | 107 ++ apps/server/src/integration/dingtalk.types.ts | 192 +++ apps/server/src/integration/endpoints.ts | 17 + 36 files changed, 5122 insertions(+), 3458 deletions(-) create mode 100644 apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx create mode 100644 apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx create mode 100644 apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx create mode 100644 apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx create mode 100644 apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx create mode 100644 apps/admin/src/pages/Attendance/admin.tsx create mode 100644 apps/admin/src/pages/Attendance/teacher.tsx create mode 100644 apps/server/src/attendance/attendance-calendar.service.ts create mode 100644 apps/server/src/attendance/attendance-device.ts create mode 100644 apps/server/src/attendance/attendance-dingtalk.ts create mode 100644 apps/server/src/attendance/attendance-generation.service.ts create mode 100644 apps/server/src/attendance/attendance-import.controller.ts create mode 100644 apps/server/src/attendance/attendance-lesson.service.ts create mode 100644 apps/server/src/attendance/attendance-mutex.ts create mode 100644 apps/server/src/attendance/attendance-query.service.ts create mode 100644 apps/server/src/attendance/attendance-record-mutation.service.ts create mode 100644 apps/server/src/attendance/attendance-records.controller.ts create mode 100644 apps/server/src/attendance/attendance-report.service.ts create mode 100644 apps/server/src/attendance/attendance-time.ts create mode 100644 apps/server/src/attendance/attendance.controller-base.ts create mode 100644 apps/server/src/integration/dingtalk.attendance.ts create mode 100644 apps/server/src/integration/dingtalk.groups.ts create mode 100644 apps/server/src/integration/dingtalk.schedules.ts create mode 100644 apps/server/src/integration/dingtalk.shifts.ts create mode 100644 apps/server/src/integration/dingtalk.types.ts create mode 100644 apps/server/src/integration/endpoints.ts diff --git a/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx b/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx new file mode 100644 index 0000000..00448f0 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx @@ -0,0 +1,199 @@ +import dayjs from 'dayjs'; +import type { AttendanceSummary } from './attendance-workspace'; +import type { LessonAttendanceRecord } from './types'; + +export type { AttendanceSummary } from './attendance-workspace'; + +export const DEFAULT_ATTENDANCE_PERIODS = [ + { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true }, + { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true }, + { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true }, + { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true }, +]; + +export const STATUS_META: Record< + string, + { label: string; color: string; className: string; short: string } +> = { + present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' }, + absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' }, + leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' }, + pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' }, +}; + +export const ADMIN_CORRECTION_OPTIONS = [ + { value: 'present', label: '正常' }, + { value: 'leave', label: '请假' }, + { value: 'absent', label: '缺勤' }, +]; + +export interface ClassTeacherOption { + userId: number; + username: string | null; + name: string | null; + roleType: string; + subject: string | null; +} + +export interface ClassOption { + classId: number; + className: string; + teachers?: ClassTeacherOption[]; +} + +export type AttendanceRecordItem = LessonAttendanceRecord; + +export interface HistoryScheduleOption { + id: number; + classId: number; + weekDay: number; + startTime: string; + endTime: string; + startDate: string; + endDate: string; + subject: string; + teacherId: number | null; + teacherName?: string | null; + teacherUsername?: string | null; + status: string; +} + +export interface AlertItem { + studentId: number; + studentName: string; + studentNo: string; + className: string; + type: string; + count: number; + lastDate: string; +} + +export interface AttendancePeriodConfigItem { + id?: number; + periodKey: string; + label: string; + startTime: string; + endTime: string; + sortOrder: number; + enabled: boolean; +} + +export interface DingTalkSyncStatus { + lastPulledAt: string | null; + action: string | null; + username: string | null; + detail: string | null; +} + +export const EMPTY_SUMMARY: AttendanceSummary = { + total: 0, + present: 0, + late: 0, + absent: 0, + leave: 0, + pending: 0, +}; + +export function displayAttendanceStatus(status?: string | null): string { + return status === 'pending' || !status ? 'absent' : status; +} + +export function getTeacherDisplayName(teacher?: { + name?: string | null; + username?: string | null; +}): string { + const name = teacher?.name?.trim(); + if (name) return name; + return teacher?.username?.trim() || '未设置'; +} + +export function formatTeacherNames( + teachers: readonly { name?: string | null; username?: string | null }[], +): string { + const names = [ + ...new Set( + teachers + .map((teacher) => getTeacherDisplayName(teacher)) + .filter((name) => name && name !== '未设置'), + ), + ]; + return names.length > 0 ? names.join('、') : '未设置'; +} + +export function AttendanceStatusTag({ status }: { status: string }) { + const displayStatus = displayAttendanceStatus(status); + const meta = STATUS_META[displayStatus] ?? { + label: displayStatus, + color: 'default', + className: 'is-absent', + short: '?', + }; + return ( + + + {meta.label} + + ); +} + +export interface AdminStudentPanel { + key: string; + studentId: number; + studentName: string; + studentNo: string; + className: string; + records: AttendanceRecordItem[]; + statusBySession: Partial>; + primaryStatus: string; + rate: number; + latestDate: string; +} + +export const ADMIN_METRIC_META = [ + { key: 'all', label: '出勤率', short: '率' }, + { key: 'present', label: '正常', short: '正常' }, + { key: 'leave', label: '请假', short: '请假' }, + { key: 'absent', label: '缺勤', short: '缺勤' }, +]; + +function pickPrimaryStatus(records: AttendanceRecordItem[]) { + const priority = ['absent', 'leave', 'present']; + return ( + priority.find((item) => + records.some((record) => displayAttendanceStatus(record.status) === item), + ) || 'absent' + ); +} + +export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] { + const map = new Map(); + for (const record of records) { + const current = map.get(record.studentId) ?? { + key: String(record.studentId), + studentId: record.studentId, + studentName: record.student?.name || '未知学生', + studentNo: record.student?.studentNo?.trim() || '', + className: record.class?.name || '未关联班级', + records: [], + statusBySession: {}, + primaryStatus: 'absent', + rate: 0, + latestDate: record.attendanceDate, + }; + current.records.push(record); + if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record; + if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) { + current.latestDate = record.attendanceDate; + } + map.set(record.studentId, current); + } + + return Array.from(map.values()).map((item) => { + const checked = item.records.filter((record) => record.status === 'present').length; + return { + ...item, + primaryStatus: pickPrimaryStatus(item.records), + rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0, + }; + }); +} diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx new file mode 100644 index 0000000..a8965de --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx @@ -0,0 +1,131 @@ +import { Avatar, Segmented, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import EditableCell from '../../components/EditableCell'; +import { getPunchDisplayInfo } from './attendance-workspace'; +import { + ADMIN_CORRECTION_OPTIONS, + AttendanceStatusTag, + displayAttendanceStatus, + type AttendanceRecordItem, +} from './AttendanceAdmin.helpers'; + +export interface AttendanceAdminColumnContext { + isMobile: boolean; + canEdit: boolean; + sessionMap: Record; + correctingRecordId: number | null; + onSaveAdminRecordCell: ( + record: AttendanceRecordItem, + field: 'status' | 'remark', + value: unknown, + ) => void; + onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void; +} + +const buildAttendanceAdminDataColumns = (ctx: AttendanceAdminColumnContext) => { + const { isMobile, canEdit, sessionMap, onSaveAdminRecordCell } = ctx; + return [ + { + title: '学生', + dataIndex: ['student', 'name'], + fixed: (isMobile ? undefined : 'left') as 'left' | undefined, + width: 150, + render: (name: string, record: AttendanceRecordItem) => ( +
+ {name?.slice(0, 1)} +
+ {name || '-'} + {record.class?.name || '未关联班级'} +
+
+ ), + }, + { title: '日期', dataIndex: 'attendanceDate', width: 120 }, + { + title: '时段', + dataIndex: 'session', + width: 110, + render: (value: string) => sessionMap[value] || value, + }, + { + title: '状态', + dataIndex: 'status', + width: 105, + render: (value: string, record: AttendanceRecordItem) => ( + ({ + value: String(item.value), + label: item.label, + }))} + disabled={!canEdit} + onSave={async (next) => { + onSaveAdminRecordCell(record, 'status', next); + }} + > + + + ), + }, + { + title: '签到来源', + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => { + const info = getPunchDisplayInfo(record); + if (!info) return ; + return ( +
+ {info.label} + {info.detail && {info.detail}} + {info.time && {dayjs(info.time).format('HH:mm:ss')}} +
+ ); + }, + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: (value: string | null, record: AttendanceRecordItem) => ( + { + onSaveAdminRecordCell(record, 'remark', next); + }} + > + {value || } + + ), + }, + ] as ColumnsType; +}; + +const buildAttendanceAdminActionColumn = (ctx: AttendanceAdminColumnContext) => { + const { isMobile, correctingRecordId, onUpdateAdminRecordStatus } = ctx; + return { + title: '操作', + key: 'action', + fixed: (isMobile ? undefined : 'right') as 'right' | undefined, + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => ( + void onUpdateAdminRecordStatus(record, String(value))} + /> + ), + }; +}; + +export const buildAttendanceAdminColumns = (ctx: AttendanceAdminColumnContext) => { + const dataColumns = buildAttendanceAdminDataColumns(ctx); + const actionColumn = ctx.canEdit ? [buildAttendanceAdminActionColumn(ctx)] : []; + return [...dataColumns, ...actionColumn] as ColumnsType; +}; diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx new file mode 100644 index 0000000..751cf26 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { + Avatar, + Button, + DatePicker, + Select, + Spin, + Tooltip, +} from 'antd'; +import { + ExportOutlined, + FileSearchOutlined, + ReloadOutlined, + ScheduleOutlined, + UndoOutlined, + WarningFilled, +} from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import { + ADMIN_METRIC_META, + STATUS_META, + type AlertItem, + type AttendanceSummary, + type ClassOption, + type DingTalkSyncStatus, + type HistoryScheduleOption, +} from './AttendanceAdmin.helpers'; + +export const AttendanceAdminHeader: React.FC<{ + syncStatus: DingTalkSyncStatus | null; + canEdit: boolean; + refreshingDingTalk: boolean; + onOpenPeriodConfig: () => void; + onRefreshDingTalk: () => void; + onExport: () => void; + attendanceDate: Dayjs | null; + onDateChange: (date: Dayjs | null) => void; + classId?: number; + onClassChange: (value?: number) => void; + classOptions: ClassOption[]; + effectiveScheduleId?: number; + onScheduleChange: (value?: number) => void; + scheduleOptions: HistoryScheduleOption[]; + scheduleOptionsLoading: boolean; + session?: string; + onSessionChange: (value?: string) => void; + sessionOptions: Array<{ value: string; label: string }>; + onReset: () => void; + onQuery: () => void; + selectedClass: string; + dateLabel: string; + visibleStudentCount: number; + total: number; + headTeacherNames: string; + lifeTeacherNames: string; + subjectTeacherNames: string; + attendanceRate: number; + summary: AttendanceSummary; + metricFilter: string; + onMetricFilterChange: (key: string) => void; + alerts: AlertItem[]; +}> = ({ + syncStatus, + canEdit, + refreshingDingTalk, + onOpenPeriodConfig, + onRefreshDingTalk, + onExport, + attendanceDate, + onDateChange, + classId, + onClassChange, + classOptions, + effectiveScheduleId, + onScheduleChange, + scheduleOptions, + scheduleOptionsLoading, + session, + onSessionChange, + sessionOptions, + onReset, + onQuery, + selectedClass, + dateLabel, + visibleStudentCount, + total, + headTeacherNames, + lifeTeacherNames, + subjectTeacherNames, + attendanceRate, + summary, + metricFilter, + onMetricFilterChange, + alerts, +}) => { + return ( + <> +
+
+

学生考勤中心

+ 班级考勤总览 +
+
+ + + + {syncStatus?.lastPulledAt + ? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}` + : '暂无钉钉拉取记录'} + + + {canEdit && ( + + )} + } + loading={refreshingDingTalk} + onClick={onRefreshDingTalk} + > + 刷新钉钉考勤 + + } onClick={onExport}> + 导出当前报表 + +
+
+ +
+
+ + current.isAfter(dayjs(), 'day')} + onChange={onDateChange} + /> +
+
+ + : '当前日期没有排课'} + onChange={onScheduleChange} + options={scheduleOptions.map((schedule) => ({ + value: schedule.id, + label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`, + }))} + /> +
+
+ + +
+
+ + +
+
+ +
+
+
+ 班级考勤概览 +

{selectedClass}

+

+ {dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录 +

+
+
+
+ +
+ 班主任 + {headTeacherNames} +
+
+
+ +
+ 生活老师 + {lifeTeacherNames} +
+
+
+ +
+ 任课老师 + {subjectTeacherNames} +
+
+
+
+
+ {ADMIN_METRIC_META.map((metric) => { + const value = + metric.key === 'all' + ? `${attendanceRate}%` + : metric.key === 'absent' + ? summary.absent + summary.pending + : (summary[metric.key as keyof AttendanceSummary] ?? 0); + const meta = STATUS_META[metric.key] ?? { className: 'is-present' }; + return ( + + ); + })} +
+
+ + {alerts.length > 0 && ( +
+ +
+ {alerts.length} 名学生存在连续异常 + 建议优先核查最近 14 天的缺勤记录 +
+ `${item.studentName}:${item.type}${item.count}次`) + .join(';')} + > + + +
+ )} + + ); +}; diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx new file mode 100644 index 0000000..3af0f74 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { + Alert, + Avatar, + Button, + Drawer, + Form, + Input, + Modal, + Segmented, + Select, +} from 'antd'; +import { UndoOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + ADMIN_CORRECTION_OPTIONS, + AttendanceStatusTag, + displayAttendanceStatus, + type AdminStudentPanel, + type AttendancePeriodConfigItem, + type AttendanceRecordItem, +} from './AttendanceAdmin.helpers'; + +export const PeriodConfigModal: React.FC<{ + open: boolean; + form: ReturnType>[0]; + onOk: () => void; + onCancel: () => void; + onReset: () => void; +}> = ({ open, form, onOk, onCancel, onReset }) => { + return ( + ( + <> + + + + + )} + > + +
+ + {(fields, { add, remove }) => ( +
+ {fields.map((field) => ( +
+ + + + + + + + + + + + + + { - setClassId(value); - setScheduleId(undefined); - setPage(1); - }} - options={classOptions.map((item) => ({ value: item.classId, label: item.className }))} - /> -
-
- - -
-
- - - - - - - - - - - - - - + setRowActions((prev) => ({ ...prev, [row.id]: value })) + } + /> + ) : null, + }, + ]; + }, [activeStepKey, rowActions]); + + const stageItems = useMemo( + () => + (run?.steps ?? []) + .filter((step) => step.status !== 'skipped') + .map((step) => ({ + key: step.stepKey, + title: step.label, + status: + step.status === 'committed' + ? ('finish' as const) + : step.stepKey === activeStepKey + ? ('process' as const) + : ('wait' as const), + })), + [run, activeStepKey], + ); + + const allCommitted = run?.status === 'committed'; + + return ( + + {!run && !loadingRun ? ( + + + +

+ +

+

点击或拖拽 .xlsx / .csv 文件到此区域

+

单文件不超过 10MB;.xls 请先另存为 .xlsx

+
+
+ ) : ( + + + + {run?.fileName} + + {allCommitted ? '已完成' : '待处理'} + + 当前阶段:{activeStep?.label ?? '—'} + + + {preview && preview.summary.error > 0 && ( + + )} + + + + + { + const step = (run?.steps ?? []).filter((s) => s.status !== 'skipped')[index]; + if (step) setActiveStepKey(step.stepKey); + }} + /> + + {loadingRun ? ( + + + + ) : allCommitted ? ( + + + step.status !== 'skipped') + .map((step) => ({ + key: step.stepKey, + label: step.label, + children: step.summary + ? `新建 ${step.summary.create} / 更新 ${step.summary.update} / 跳过 ${step.summary.skip} / 失败 ${step.summary.error}` + : '—', + }))} + /> + + + ) : activeStep ? ( + + {receipt && ( + setReceipt(null)} + /> + )} + {activeStep.status === 'committed' ? ( + + ) : preview ? ( + + + + 共 {preview.summary.total} 行 + 有效 {preview.summary.valid} + 错误 {preview.summary.error} + 新建 {preview.summary.create} + 更新 {preview.summary.update} + + setOnlyErrors(e.target.checked)} + > + 只看错误行 + + + + + { + void handleReupload(file); + return false; + }} + > + + + + + + ) : ( + + + + 工作表 + ({ value: header, label: header }))} + onChange={(value?: string) => + setMappingDraft((prev) => { + const current = { ...prev[activeStepKey ?? ''] }; + if (value) current[field.key] = value; + else delete current[field.key]; + return { ...prev, [activeStepKey ?? '']: current }; + }) + } + /> + + ))} + + + + + )} + + ) : ( + + )} + + )} + + ); +}; diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts new file mode 100644 index 0000000..097b361 --- /dev/null +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -0,0 +1,122 @@ +export type ImportStepKey = 'students' | 'rooms' | 'checkins' | 'transfers'; + +export interface ImportSheetMeta { + name: string; + headers: string[]; + rowCount: number; + suggestedStepKey: ImportStepKey | null; +} + +export interface ImportStepSummary { + total: number; + valid: number; + error: number; + create: number; + update: number; + skip: number; +} + +export interface ImportStepDetail { + id: number; + stepKey: ImportStepKey; + label: string; + sheets: string[]; + status: 'pending' | 'ready' | 'committing' | 'committed' | 'failed' | 'skipped'; + mapping: Record; + summary: ImportStepSummary | null; + committedAt: string | null; +} + +export interface ImportRunDetail { + id: string; + fileName: string; + source: 'ai' | 'manual'; + status: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired'; + currentStepKey: ImportStepKey | null; + createdAt: string; + sheets: ImportSheetMeta[]; + steps: ImportStepDetail[]; +} + +export interface ImportStageRequest { + stepKey: ImportStepKey; + sheet?: string; + headerRow?: number; +} + +export interface ImportPreviewRow { + id: number; + rowNumber: number; + sheetName: string; + raw: Record; + fields: Record; + action: 'create' | 'update' | 'skip' | null; + status: 'pending' | 'valid' | 'error' | 'committed' | 'skipped'; + errors: string[]; +} + +export interface ImportPreviewResult { + stepKey: ImportStepKey; + sheetNames: string[]; + headers: string[]; + mapping: Record; + rows: ImportPreviewRow[]; + summary: ImportStepSummary; +} + +export interface ImportReceipt { + runId: string; + stepKey: ImportStepKey; + status: 'committed' | 'already_committed' | 'conflict'; + created: number; + updated: number; + skipped: number; + failed: number; + total: number; + nextStepKey: ImportStepKey | null; + runStatus: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired'; + message: string; +} + +export const STEP_FIELDS: Record< + ImportStepKey, + Array<{ key: string; label: string; required?: boolean; identity?: boolean }> +> = { + students: [ + { key: 'name', label: '姓名', required: true }, + { key: 'studentNo', label: '学号', identity: true }, + { key: 'phone', label: '手机号', identity: true }, + { key: 'gender', label: '性别' }, + { key: 'idNumber', label: '身份证号' }, + { key: 'ethnicity', label: '民族' }, + { key: 'emergencyContact', label: '紧急联系人' }, + { key: 'emergencyPhone', label: '紧急联系电话' }, + { key: 'organization', label: '校区' }, + { key: 'status', label: '状态' }, + ], + rooms: [ + { key: 'roomNumber', label: '宿舍号', required: true }, + { key: 'building', label: '楼栋' }, + { key: 'floor', label: '楼层' }, + { key: 'capacity', label: '容量', required: true }, + { key: 'roomType', label: '房型' }, + { key: 'rentalCategory', label: '租期类型' }, + { key: 'monthlyRate', label: '月租' }, + ], + checkins: [ + { key: 'name', label: '姓名' }, + { key: 'studentNo', label: '学号', identity: true }, + { key: 'phone', label: '手机号', identity: true }, + { key: 'roomNumber', label: '宿舍号', required: true }, + { key: 'checkInDate', label: '入住日期', required: true }, + { key: 'stayType', label: '住宿类型' }, + ], + transfers: [ + { key: 'studentNo', label: '学号', identity: true }, + { key: 'phone', label: '手机号', identity: true }, + { key: 'oldRoom', label: '原宿舍', required: true }, + { key: 'newRoom', label: '新宿舍', required: true }, + { key: 'transferDate', label: '换宿日期', required: true }, + { key: 'reason', label: '原因/备注' }, + ], +}; diff --git a/apps/server/src/imports/entities/import-row.entity.ts b/apps/server/src/imports/entities/import-row.entity.ts new file mode 100644 index 0000000..9d4a25d --- /dev/null +++ b/apps/server/src/imports/entities/import-row.entity.ts @@ -0,0 +1,46 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import type { ImportRowAction, ImportRowStatus } from '../imports.types'; + +@Entity('import_rows') +@Index('idx_import_rows_step', ['stepId']) +@Index('idx_import_rows_run_status', ['runId', 'status']) +export class ImportRow { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'run_id', type: 'varchar', length: 36 }) + runId: string; + + @Column({ name: 'step_id', type: 'integer' }) + stepId: number; + + @Column({ name: 'sheet_name', type: 'varchar', length: 200 }) + sheetName: string; + + @Column({ name: 'row_number', type: 'integer' }) + rowNumber: number; + + @Column({ name: 'raw_json', type: 'text' }) + rawJson: string; + + @Column({ name: 'normalized_json', type: 'text', nullable: true }) + normalizedJson: string | null; + + @Column({ name: 'match_key', type: 'varchar', length: 200, nullable: true }) + matchKey: string | null; + + @Column({ name: 'action', type: 'varchar', length: 10, nullable: true }) + action: ImportRowAction | null; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ImportRowStatus; + + @Column({ name: 'errors_json', type: 'text', nullable: true }) + errorsJson: string | null; + + @Column({ name: 'target_id', type: 'integer', nullable: true }) + targetId: number | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/server/src/imports/entities/import-run.entity.ts b/apps/server/src/imports/entities/import-run.entity.ts new file mode 100644 index 0000000..dba89fe --- /dev/null +++ b/apps/server/src/imports/entities/import-run.entity.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import type { ImportRunSource, ImportRunStatus, ImportStepKey } from '../imports.types'; + +@Entity('import_runs') +@Index('idx_import_runs_user_created', ['userId', 'createdAt']) +export class ImportRun { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id: string; + + @Column({ name: 'user_id', type: 'integer' }) + userId: number; + + @Column({ name: 'conversation_id', type: 'integer', nullable: true }) + conversationId: number | null; + + @Column({ type: 'varchar', length: 10, default: 'manual' }) + source: ImportRunSource; + + @Column({ name: 'file_name', type: 'varchar', length: 255 }) + fileName: string; + + /** Serialized sheet data — parsed rows are kept here for v1. */ + @Column({ name: 'sheets_json', type: 'text' }) + sheetsJson: string; + + @Column({ type: 'varchar', length: 20, default: 'preparing' }) + status: ImportRunStatus; + + @Column({ name: 'current_step_key', type: 'varchar', length: 20, nullable: true }) + currentStepKey: ImportStepKey | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + error: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/imports/entities/import-step.entity.ts b/apps/server/src/imports/entities/import-step.entity.ts new file mode 100644 index 0000000..85db1d7 --- /dev/null +++ b/apps/server/src/imports/entities/import-step.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import type { ImportStepKey, ImportStepStatus } from '../imports.types'; + +@Entity('import_steps') +@Index('idx_import_steps_run_key', ['runId', 'stepKey']) +export class ImportStep { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'run_id', type: 'varchar', length: 36 }) + runId: string; + + @Column({ name: 'step_key', type: 'varchar', length: 20 }) + stepKey: ImportStepKey; + + /** Serialized string[] of sheet names assigned to this stage. */ + @Column({ name: 'sheets_json', type: 'text' }) + sheetsJson: string; + + @Column({ name: 'mapping_json', type: 'text', nullable: true }) + mappingJson: string | null; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ImportStepStatus; + + /** Serialized StepPreviewSummary of the last commit. */ + @Column({ name: 'summary_json', type: 'text', nullable: true }) + summaryJson: string | null; + + @Column({ name: 'committed_at', type: 'datetime', nullable: true }) + committedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/server/src/imports/imports.access.ts b/apps/server/src/imports/imports.access.ts new file mode 100644 index 0000000..0d77123 --- /dev/null +++ b/apps/server/src/imports/imports.access.ts @@ -0,0 +1,47 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { IMPORT_STEP_LABELS } from './imports.types'; +import type { ImportStepKey } from './imports.types'; + +export interface ImportPrincipal { + id: number; + permissions: string[]; + isSuperAdmin: boolean; +} + +const STEP_PERMISSIONS: Record = { + students: ['student:import'], + rooms: ['room:create', 'room:edit'], + checkins: ['occupancy:checkin'], + transfers: ['occupancy:transfer'], +}; + +export async function findOwnedRun( + runs: Repository, + userId: number, + runId: string, +): Promise { + const run = await runs.findOne({ where: { id: runId, userId } }); + if (!run) throw new NotFoundException('导入任务不存在'); + return run; +} + +export async function findStep( + steps: Repository, + runId: string, + stepKey: ImportStepKey, +): Promise { + return steps.findOne({ where: { runId, stepKey } }); +} + +export function assertStepPermission(principal: ImportPrincipal, stepKey: ImportStepKey): void { + if (principal.isSuperAdmin) return; + const required = STEP_PERMISSIONS[stepKey]; + if (!required.some((code) => principal.permissions.includes(code))) { + throw new ForbiddenException( + `权限不足:提交「${IMPORT_STEP_LABELS[stepKey]}」需要 ${required.join(' 或 ')}`, + ); + } +} diff --git a/apps/server/src/imports/imports.commit.service.ts b/apps/server/src/imports/imports.commit.service.ts new file mode 100644 index 0000000..9a2be99 --- /dev/null +++ b/apps/server/src/imports/imports.commit.service.ts @@ -0,0 +1,247 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { ImportRow } from './entities/import-row.entity'; +import { IMPORT_ACTION_LABELS, IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types'; +import type { + CellValue, + ImportRowAction, + ImportRowDecision, + ImportStepKey, + StepCommitReceipt, + StepPreviewSummary, +} from './imports.types'; +import { csvCell, parseJson, safeError } from './imports.helpers'; +import { writeRow } from './imports.rows'; +import { assertStepPermission, findOwnedRun, findStep } from './imports.access'; +import type { ImportPrincipal } from './imports.access'; + +@Injectable() +export class ImportCommitService { + constructor( + @InjectRepository(ImportRun) + private readonly runs: Repository, + @InjectRepository(ImportStep) + private readonly steps: Repository, + @InjectRepository(ImportRow) + private readonly rows: Repository, + private readonly dataSource: DataSource, + ) {} + + async commitStep( + principal: ImportPrincipal, + runId: string, + stepKey: ImportStepKey, + decisions: ImportRowDecision[], + ): Promise { + const run = await findOwnedRun(this.runs, principal.id, runId); + if (run.status === 'committed') { + const receipt = await this.existingReceipt(run, stepKey); + return { ...receipt, status: 'already_committed' }; + } + if (run.currentStepKey !== stepKey) { + return { + runId, + stepKey, + status: 'conflict', + created: 0, + updated: 0, + skipped: 0, + failed: 0, + total: 0, + nextStepKey: run.currentStepKey, + runStatus: run.status, + message: `请先完成「${run.currentStepKey ? IMPORT_STEP_LABELS[run.currentStepKey] : ''}」阶段`, + }; + } + const step = await findStep(this.steps, runId, stepKey); + if (!step || step.status === 'skipped') { + throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`); + } + if (step.status === 'committed') { + const receipt = await this.existingReceipt(run, stepKey); + return { ...receipt, status: 'already_committed' }; + } + if (step.status !== 'ready') { + throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」尚未预览,请先预览确认`); + } + assertStepPermission(principal, stepKey); + + const pendingRows = await this.rows.find({ where: { stepId: step.id, status: 'valid' } }); + const decisionMap = new Map(); + for (const decision of decisions ?? []) { + if ( + Number.isInteger(decision.rowId) && + (decision.action === 'create' || decision.action === 'update' || decision.action === 'skip') + ) { + decisionMap.set(decision.rowId, decision.action); + } + } + if (pendingRows.length === 0) { + throw new BadRequestException('没有可提交的有效行,请检查预览结果'); + } + const rowById = new Map(pendingRows.map((row) => [row.id, row])); + for (const [rowId, action] of decisionMap) { + const row = rowById.get(rowId); + if (!row) continue; + if (action === 'skip') continue; + if (!row.action) { + throw new BadRequestException( + `第 ${row.rowNumber} 行(${row.sheetName})没有预览判定,只能选择「跳过」`, + ); + } + if (action !== row.action) { + throw new BadRequestException( + `第 ${row.rowNumber} 行(${row.sheetName})预览判定为「${IMPORT_ACTION_LABELS[row.action]}」,不能改为「${IMPORT_ACTION_LABELS[action]}」`, + ); + } + } + + run.status = 'committing'; + step.status = 'committing'; + await this.runs.save(run); + await this.steps.save(step); + + const counts = { created: 0, updated: 0, skipped: 0, failed: 0 }; + try { + await this.dataSource.transaction(async (manager) => { + for (const row of pendingRows) { + const action = decisionMap.get(row.id) ?? row.action ?? 'create'; + if (action === 'skip') { + row.status = 'skipped'; + row.action = 'skip'; + counts.skipped += 1; + await manager.save(ImportRow, row); + continue; + } + try { + const fields = parseJson>(row.normalizedJson) ?? {}; + const targetId = await writeRow(manager, stepKey, action, fields, row.targetId); + row.status = 'committed'; + row.action = action; + row.targetId = targetId ?? row.targetId; + if (action === 'create') counts.created += 1; + else counts.updated += 1; + } catch (error) { + row.status = 'error'; + row.errorsJson = JSON.stringify([`写入失败:${safeError(error)}`]); + counts.failed += 1; + } + await manager.save(ImportRow, row); + } + }); + } catch (error) { + run.status = 'failed'; + run.error = safeError(error).slice(0, 500); + await this.runs.save(run); + throw new ConflictException(`提交失败:${safeError(error)}`); + } + + const summary: StepPreviewSummary = { + total: pendingRows.length, + valid: counts.created + counts.updated + counts.skipped, + error: counts.failed, + create: counts.created, + update: counts.updated, + skip: counts.skipped, + }; + step.status = 'committed'; + step.committedAt = new Date(); + step.summaryJson = JSON.stringify(summary); + await this.steps.save(step); + + const nextStepKey = await this.nextStepKey(runId, stepKey); + run.currentStepKey = nextStepKey; + run.status = nextStepKey ? 'ready' : 'committed'; + await this.runs.save(run); + + const message = + `阶段「${IMPORT_STEP_LABELS[stepKey]}」提交完成:新建 ${counts.created}、更新 ${counts.updated}、跳过 ${counts.skipped}、失败 ${counts.failed};` + + (nextStepKey ? `下一步:${IMPORT_STEP_LABELS[nextStepKey]}` : '全部阶段已完成'); + return { + runId, + stepKey, + status: 'committed', + created: counts.created, + updated: counts.updated, + skipped: counts.skipped, + failed: counts.failed, + total: pendingRows.length, + nextStepKey, + runStatus: run.status, + message, + }; + } + + async errorReport( + userId: number, + runId: string, + stepKey?: ImportStepKey, + ): Promise<{ filename: string; buffer: Buffer }> { + const run = await findOwnedRun(this.runs, userId, runId); + const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } }); + const stepIds = stepKey + ? stepRecords.filter((s) => s.stepKey === stepKey).map((s) => s.id) + : stepRecords.map((s) => s.id); + if (stepIds.length === 0) return { filename: '', buffer: Buffer.from('') }; + const rows = await this.rows.find({ + where: { stepId: In(stepIds), status: 'error' }, + order: { id: 'ASC' }, + }); + const lines: string[] = ['工作表,行号,原始数据,错误信息']; + for (const row of rows) { + const raw = parseJson>(row.rawJson) ?? {}; + const errors = parseJson(row.errorsJson) ?? []; + lines.push( + [ + csvCell(row.sheetName), + String(row.rowNumber), + csvCell(JSON.stringify(raw)), + csvCell(errors.join(';')), + ].join(','), + ); + } + return { + filename: `导入错误报告-${run.fileName.replace(/\.(xlsx|csv)$/i, '')}.csv`, + buffer: Buffer.from(`\uFEFF${lines.join('\n')}`, 'utf8'), + }; + } + + private async nextStepKey( + runId: string, + currentKey: ImportStepKey, + ): Promise { + const stepRecords = await this.steps.find({ where: { runId } }); + const currentIndex = IMPORT_STEP_ORDER.indexOf(currentKey); + for (let i = currentIndex + 1; i < IMPORT_STEP_ORDER.length; i += 1) { + const candidate = IMPORT_STEP_ORDER[i]; + const step = stepRecords.find((s) => s.stepKey === candidate); + if (step && step.status !== 'skipped' && step.status !== 'committed') { + return candidate; + } + } + return null; + } + + private async existingReceipt( + run: ImportRun, + stepKey: ImportStepKey, + ): Promise> { + const step = await findStep(this.steps, run.id, stepKey); + const summary = parseJson(step?.summaryJson); + return { + runId: run.id, + stepKey, + created: summary?.create ?? 0, + updated: summary?.update ?? 0, + skipped: summary?.skip ?? 0, + failed: summary?.error ?? 0, + total: summary?.total ?? 0, + nextStepKey: run.currentStepKey, + runStatus: run.status, + message: `阶段「${IMPORT_STEP_LABELS[stepKey]}」此前已提交`, + }; + } +} diff --git a/apps/server/src/imports/imports.controller.ts b/apps/server/src/imports/imports.controller.ts new file mode 100644 index 0000000..2edf23a --- /dev/null +++ b/apps/server/src/imports/imports.controller.ts @@ -0,0 +1,167 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Request, Response } from 'express'; +import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; +import { + IMPORT_STEP_KEYS, + type ImportRowDecision, + type ImportStageRequest, + type ImportStepKey, +} from './imports.types'; +import { ImportsService } from './imports.service'; + +interface AuthenticatedRequest extends Request { + user: AuthenticatedUser; +} + +const IMPORT_GATE_PERMISSIONS = [ + 'student:import', + 'room:create', + 'room:edit', + 'occupancy:checkin', + 'occupancy:transfer', +] as const; + +@Controller('imports') +@RequirePermission(...IMPORT_GATE_PERMISSIONS) +export class ImportsController { + constructor(private readonly importsService: ImportsService) {} + + @Post('runs') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) + async create( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: Express.Multer.File | undefined, + @Body() body: Record, + ) { + if (!file) throw new BadRequestException('缺少上传文件'); + let stages: ImportStageRequest[] | undefined; + if (typeof body.stages === 'string' && body.stages.trim()) { + try { + const parsed = JSON.parse(body.stages) as unknown; + if (!Array.isArray(parsed)) throw new Error('not array'); + stages = parsed as ImportStageRequest[]; + } catch { + throw new BadRequestException('stages 参数格式错误'); + } + } + let mapping: Partial>> | undefined; + if (typeof body.mapping === 'string' && body.mapping.trim()) { + try { + const parsed = JSON.parse(body.mapping) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + mapping = parsed; + } + } catch { + throw new BadRequestException('mapping 参数格式错误'); + } + } + const conversationId = + body.conversationId !== undefined ? Number(body.conversationId) : undefined; + const source = body.source === 'ai' ? 'ai' : 'manual'; + const data = await this.importsService.createRun( + this.principal(req.user), + source, + { + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + buffer: file.buffer, + }, + Number.isFinite(conversationId) ? conversationId : undefined, + stages, + mapping, + ); + return { success: true, data }; + } + + @Get('runs/:id') + async get(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return { success: true, data: await this.importsService.getRun(req.user.id, id) }; + } + + @Post('runs/:id/steps/:stepKey/preview') + async preview( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Param('stepKey') stepKey: string, + @Body() body: { sheets?: string[]; mapping?: Record }, + ) { + const data = await this.importsService.previewStep( + this.principal(req.user), + id, + this.parseStepKey(stepKey), + body ?? {}, + ); + return { success: true, data }; + } + + @Post('runs/:id/steps/:stepKey/commit') + async commit( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Param('stepKey') stepKey: string, + @Body() body: { decisions?: ImportRowDecision[] }, + ) { + const data = await this.importsService.commitStep( + this.principal(req.user), + id, + this.parseStepKey(stepKey), + Array.isArray(body?.decisions) ? body.decisions : [], + ); + return { success: true, data }; + } + + @Get('runs/:id/report') + async report( + @Req() req: AuthenticatedRequest, + @Res() res: Response, + @Param('id') id: string, + @Query('stepKey') stepKey?: string, + ) { + const { filename, buffer } = await this.importsService.errorReport( + req.user.id, + id, + stepKey ? this.parseStepKey(stepKey) : undefined, + ); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader( + 'Content-Disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(filename || 'import-errors.csv')}`, + ); + res.setHeader('Content-Length', String(buffer.length)); + res.send(buffer); + } + + private parseStepKey(value: string): ImportStepKey { + if ((IMPORT_STEP_KEYS as readonly string[]).includes(value)) { + return value as ImportStepKey; + } + throw new BadRequestException(`未知导入阶段:${value}`); + } + + private principal(user: AuthenticatedUser): { + id: number; + permissions: string[]; + isSuperAdmin: boolean; + } { + return { + id: user.id, + permissions: user.permissions, + isSuperAdmin: user.isSuperAdmin, + }; + } +} diff --git a/apps/server/src/imports/imports.helpers.ts b/apps/server/src/imports/imports.helpers.ts new file mode 100644 index 0000000..beeb9b6 --- /dev/null +++ b/apps/server/src/imports/imports.helpers.ts @@ -0,0 +1,92 @@ +import * as ExcelJS from 'exceljs'; +import type { CellValue } from './imports.types'; + +export function parseJson(raw: string | null | undefined): T | null { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function textValue(value: CellValue): string { + if (value === null || value === undefined) return ''; + return String(value).trim(); +} + +export function normalizeHeader(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s()()]/g, ''); +} + +export function headerMatches(header: string, alias: string): boolean { + const h = normalizeHeader(header); + const a = normalizeHeader(alias); + if (!h || !a) return false; + return h === a || h.includes(a) || a.includes(h); +} + +export function cellValue(cell: ExcelJS.Cell | undefined): CellValue { + if (!cell) return null; + const value = cell.value; + if (value === null || value === undefined) return null; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (value instanceof Date) return value; + if (typeof value === 'object') { + const candidate = value as { text?: unknown; result?: unknown }; + if (typeof candidate.text === 'string') return candidate.text; + if (typeof candidate.result === 'string' || typeof candidate.result === 'number') { + return candidate.result; + } + if (candidate.result instanceof Date) return candidate.result; + } + return null; +} + +export function parseDateValue(value: CellValue): string | null { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.toISOString().slice(0, 10); + } + const raw = textValue(value); + if (!raw) return null; + const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(raw); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +export function safeError(error: unknown): string { + if (error instanceof Error) return error.message.slice(0, 120); + return '未知错误'; +} + +export function applyString(target: object, key: string, value: CellValue): void { + const text = textValue(value); + if (text) (target as Record)[key] = text; +} + +export function optionalNumber(value: CellValue): number | null { + const text = textValue(value); + if (!text) return null; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +} + +export function csvCell(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} diff --git a/apps/server/src/imports/imports.lookups.ts b/apps/server/src/imports/imports.lookups.ts new file mode 100644 index 0000000..5f427e9 --- /dev/null +++ b/apps/server/src/imports/imports.lookups.ts @@ -0,0 +1,118 @@ +import { DataSource, In } from 'typeorm'; +import { Organization } from '../entities/organization.entity'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { textValue } from './imports.helpers'; +import type { CellValue, ColumnMapping, ImportStepKey } from './imports.types'; + +export interface ImportLookups { + studentsByNo: Map; + studentsByPhone: Map; + roomsByNumber: Map; + activeOccupancies: Map; + organizations: Map; +} + +export async function buildLookups( + dataSource: DataSource, + stepKey: ImportStepKey, + headers: string[], + rows: CellValue[][], + mapping: ColumnMapping, +): Promise { + const studentNos = new Set(); + const phones = new Set(); + const roomNumbers = new Set(); + const organizationNames = new Set(); + const fieldIndex: Record = {}; + for (const [field, header] of Object.entries(mapping)) { + const index = headers.indexOf(header); + if (index >= 0) fieldIndex[field] = index; + } + const valueOf = (row: CellValue[], field: string): CellValue => { + const index = fieldIndex[field]; + return index === undefined ? null : (row[index] ?? null); + }; + for (const row of rows) { + if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') { + const no = textValue(valueOf(row, 'studentNo')); + if (no) studentNos.add(no); + const phone = textValue(valueOf(row, 'phone')); + if (phone) phones.add(phone); + } + if (stepKey === 'rooms' || stepKey === 'checkins' || stepKey === 'transfers') { + const roomField = stepKey === 'transfers' ? 'oldRoom' : 'roomNumber'; + const newRoomField = stepKey === 'transfers' ? 'newRoom' : undefined; + const roomNo = textValue(valueOf(row, roomField)); + if (roomNo) roomNumbers.add(roomNo); + if (newRoomField) { + const newRoomNo = textValue(valueOf(row, newRoomField)); + if (newRoomNo) roomNumbers.add(newRoomNo); + } + } + if (stepKey === 'students') { + const org = textValue(valueOf(row, 'organization')); + if (org) organizationNames.add(org); + } + } + + const students: Student[] = []; + if (studentNos.size > 0) { + students.push( + ...(await dataSource.getRepository(Student).find({ + where: { studentNo: In([...studentNos]) }, + })), + ); + } + if (phones.size > 0) { + students.push( + ...(await dataSource.getRepository(Student).find({ + where: { phone: In([...phones]) }, + })), + ); + } + const studentsByNo = new Map(); + const studentsByPhone = new Map(); + for (const student of students) { + if (student.studentNo) studentsByNo.set(student.studentNo, student); + if (student.phone) studentsByPhone.set(student.phone, student); + } + + const rooms = + roomNumbers.size > 0 + ? await dataSource.getRepository(Room).find({ + where: { roomNumber: In([...roomNumbers]) }, + }) + : []; + const roomsByNumber = new Map(); + for (const room of rooms) roomsByNumber.set(room.roomNumber, room); + + const organizations = + organizationNames.size > 0 ? await dataSource.getRepository(Organization).find() : []; + const organizationsByName = new Map(); + for (const org of organizations) organizationsByName.set(org.name, org); + + const activeOccupancies = new Map(); + if (stepKey === 'checkins' || stepKey === 'transfers') { + const studentIds = [...new Set(students.map((s) => s.id))]; + if (studentIds.length > 0) { + const occupancies = await dataSource.getRepository(Occupancy).find({ + where: { studentId: In(studentIds), status: 'active' }, + }); + for (const occupancy of occupancies) { + const list = activeOccupancies.get(occupancy.studentId) ?? []; + list.push(occupancy); + activeOccupancies.set(occupancy.studentId, list); + } + } + } + + return { + studentsByNo, + studentsByPhone, + roomsByNumber, + activeOccupancies, + organizations: organizationsByName, + }; +} diff --git a/apps/server/src/imports/imports.mapping.ts b/apps/server/src/imports/imports.mapping.ts new file mode 100644 index 0000000..4c3b9ef --- /dev/null +++ b/apps/server/src/imports/imports.mapping.ts @@ -0,0 +1,107 @@ +import { BadRequestException } from '@nestjs/common'; +import { + IMPORT_FIELD_ALIASES, + IMPORT_STEP_IDENTITY_FIELDS, + IMPORT_STEP_LABELS, + IMPORT_STEP_ORDER, + IMPORT_STEP_REQUIRED_FIELDS, +} from './imports.types'; +import type { + ColumnMapping, + ImportStageRequest, + ImportStageSuggestion, + ImportStepKey, +} from './imports.types'; +import { headerMatches } from './imports.helpers'; +import type { ImportSheetData } from './imports.workbook'; + +export function suggestMapping(headers: string[], stepKey: ImportStepKey): ColumnMapping { + const mapping: ColumnMapping = {}; + for (const [field, aliases] of Object.entries(IMPORT_FIELD_ALIASES[stepKey])) { + const found = headers.find((header) => aliases.some((alias) => headerMatches(header, alias))); + if (found) mapping[field] = found; + } + return mapping; +} + +export function suggestStep(headers: string[]): ImportStageSuggestion | null { + let best: ImportStageSuggestion | null = null; + for (const stepKey of IMPORT_STEP_ORDER) { + const mapping = suggestMapping(headers, stepKey); + if (Object.keys(mapping).length === 0) continue; + const identity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].filter( + (field) => mapping[field], + ).length; + const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter( + (field) => mapping[field], + ).length; + const score = Object.keys(mapping).length + identity * 3 + required * 2; + if (!best || score > best.matchedFields) { + best = { stepKey, mapping, matchedFields: score }; + } + } + return best; +} + +export function autoAssignedSheets( + sheets: ImportSheetData[], + stepKey: ImportStepKey, +): string[] { + return sheets + .filter((sheet) => suggestStep(sheet.headers)?.stepKey === stepKey) + .map((sheet) => sheet.name); +} + +export function resolveAssignedSheets( + stages: ImportStageRequest[], + stepKey: ImportStepKey, + available: string[], +): string[] { + const names = stages + .filter((stage) => stage.stepKey === stepKey && stage.sheet) + .map((stage) => stage.sheet as string); + const missing = names.filter((name) => !available.includes(name)); + if (missing.length > 0) { + throw new BadRequestException(`工作表不存在:${missing.join('、')}`); + } + return [...new Set(names)]; +} + +export function assertMapping( + stepKey: ImportStepKey, + mapping: ColumnMapping, + sheetsData: ImportSheetData[], + usedSheets: string[], +): void { + const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey]; + const missingRequired = required.filter((field) => !mapping[field]); + if (missingRequired.length > 0) { + const labels: Record = { + name: '姓名', + roomNumber: '宿舍号', + capacity: '容量', + checkInDate: '入住日期', + oldRoom: '原宿舍', + newRoom: '新宿舍', + transferDate: '换宿日期', + }; + throw new BadRequestException( + `阶段「${IMPORT_STEP_LABELS[stepKey]}」缺少必需列映射:${missingRequired + .map((field) => labels[field] ?? field) + .join('、')}`, + ); + } + if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') { + const hasIdentity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].some((field) => mapping[field]); + if (!hasIdentity) { + throw new BadRequestException('请至少映射“学号”或“手机号”列用于匹配学生'); + } + } + const usedHeaders = new Set( + usedSheets.flatMap((name) => sheetsData.find((s) => s.name === name)?.headers ?? []), + ); + const missingHeaders = Object.values(mapping).filter((header) => !usedHeaders.has(header)); + if (missingHeaders.length > 0) { + throw new BadRequestException(`映射的列不存在于所选工作表:${missingHeaders.join('、')}`); + } +} diff --git a/apps/server/src/imports/imports.module.ts b/apps/server/src/imports/imports.module.ts new file mode 100644 index 0000000..a88d751 --- /dev/null +++ b/apps/server/src/imports/imports.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { ImportRow } from './entities/import-row.entity'; +import { ImportsController } from './imports.controller'; +import { ImportsService } from './imports.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ImportRun, ImportStep, ImportRow, Student, Room, Occupancy]), + ], + controllers: [ImportsController], + providers: [ImportsService], + exports: [ImportsService], +}) +export class ImportsModule {} diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts new file mode 100644 index 0000000..dc71118 --- /dev/null +++ b/apps/server/src/imports/imports.preview.service.ts @@ -0,0 +1,163 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { ImportRow } from './entities/import-row.entity'; +import { IMPORT_STEP_LABELS } from './imports.types'; +import type { + CellValue, + ColumnMapping, + ImportStepKey, + StepPreviewSummary, +} from './imports.types'; +import { parseJson } from './imports.helpers'; +import { assertMapping, suggestMapping } from './imports.mapping'; +import { buildLookups } from './imports.lookups'; +import { validateRow } from './imports.rows'; +import type { ImportBatchState } from './imports.rows'; +import { findOwnedRun, findStep } from './imports.access'; +import type { ImportPrincipal } from './imports.access'; + +@Injectable() +export class ImportPreviewService { + constructor( + @InjectRepository(ImportRun) + private readonly runs: Repository, + @InjectRepository(ImportStep) + private readonly steps: Repository, + @InjectRepository(ImportRow) + private readonly rows: Repository, + private readonly dataSource: DataSource, + ) {} + + async previewStep( + principal: ImportPrincipal, + runId: string, + stepKey: ImportStepKey, + body: { sheets?: string[]; mapping?: ColumnMapping }, + ) { + const run = await findOwnedRun(this.runs, principal.id, runId); + if (run.status === 'committed') { + throw new BadRequestException('该导入任务已完成,无需再次预览'); + } + if (run.status === 'committing') { + throw new ConflictException('导入正在提交中,请稍候'); + } + const step = await findStep(this.steps, runId, stepKey); + if (!step || step.status === 'skipped') { + throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`); + } + if (step.status === 'committed') { + throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」已提交,不能重复预览`); + } + + const sheetsData = + parseJson>(run.sheetsJson) ?? + []; + const sheetNames = body.sheets?.length + ? body.sheets + : (parseJson(step.sheetsJson) ?? []); + const usedSheets = sheetNames.filter((name) => sheetsData.some((s) => s.name === name)); + if (usedSheets.length === 0) { + throw new BadRequestException('指定的工作表不存在'); + } + + const mapping = + body.mapping && Object.keys(body.mapping).length > 0 + ? body.mapping + : (parseJson(step.mappingJson) ?? + suggestMapping(sheetsData[0]?.headers ?? [], stepKey)); + assertMapping(stepKey, mapping, sheetsData, usedSheets); + + await this.rows.delete({ stepId: step.id }); + const rowEntities: ImportRow[] = []; + const summary: StepPreviewSummary = { + total: 0, + valid: 0, + error: 0, + create: 0, + update: 0, + skip: 0, + }; + const batchState: ImportBatchState = { + checkinStudentIds: new Set(), + transferStudentIds: new Set(), + }; + + for (const sheetName of usedSheets) { + const sheet = sheetsData.find((s) => s.name === sheetName); + if (!sheet) continue; + const lookups = await buildLookups( + this.dataSource, + stepKey, + sheet.headers, + sheet.rows, + mapping, + ); + for (let i = 0; i < sheet.rows.length; i += 1) { + const rawValues = sheet.rows[i]; + const raw: Record = {}; + sheet.headers.forEach((header, index) => { + raw[header] = rawValues[index] ?? null; + }); + const fields: Record = {}; + for (const [field, header] of Object.entries(mapping)) { + fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; + } + const result = validateRow(stepKey, fields, lookups, batchState); + const normalized = { ...result.normalized, ...result.resolvedIds }; + summary.total += 1; + if (result.errors.length > 0) { + summary.error += 1; + } else { + summary.valid += 1; + if (result.action === 'create') summary.create += 1; + if (result.action === 'update') summary.update += 1; + if (result.action === 'create') { + const studentId = result.resolvedIds._studentId; + if (studentId !== undefined) { + if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId); + if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId); + } + } + } + rowEntities.push( + this.rows.create({ + runId, + stepId: step.id, + sheetName, + rowNumber: i + 2, + rawJson: JSON.stringify(raw), + normalizedJson: JSON.stringify(normalized), + matchKey: result.matchKey, + action: result.action, + status: result.errors.length > 0 ? 'error' : 'valid', + errorsJson: result.errors.length > 0 ? JSON.stringify(result.errors) : null, + targetId: result.targetId ?? null, + }), + ); + } + } + + await this.rows.save(rowEntities); + step.sheetsJson = JSON.stringify(usedSheets); + step.mappingJson = JSON.stringify(mapping); + step.status = 'ready'; + step.summaryJson = JSON.stringify(summary); + await this.steps.save(step); + + const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? []; + const rows = rowEntities.map((entity) => ({ + id: entity.id, + rowNumber: entity.rowNumber, + sheetName: entity.sheetName, + raw: parseJson>(entity.rawJson) ?? {}, + fields: parseJson>(entity.normalizedJson) ?? {}, + action: entity.action, + status: entity.status, + errors: parseJson(entity.errorsJson) ?? [], + })); + return { stepKey, sheetNames: usedSheets, headers, mapping, rows, summary }; + } +} diff --git a/apps/server/src/imports/imports.rows.ts b/apps/server/src/imports/imports.rows.ts new file mode 100644 index 0000000..241c600 --- /dev/null +++ b/apps/server/src/imports/imports.rows.ts @@ -0,0 +1,341 @@ +import { EntityManager } from 'typeorm'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { applyString, optionalNumber, parseDateValue, textValue } from './imports.helpers'; +import type { ImportLookups } from './imports.lookups'; +import type { + CellValue, + ImportRowAction, + ImportStepKey, +} from './imports.types'; + +const PHONE_RE = /^1[3-9]\d{9}$/; + +/** 预览批次内的动态状态,用于阻止同一文件中产生重复的在住/换宿记录。 */ +export interface ImportBatchState { + checkinStudentIds: Set; + transferStudentIds: Set; +} + +export interface ValidatedRow { + errors: string[]; + action: ImportRowAction | null; + matchKey: string | null; + targetId: number | null; + normalized: Record; + resolvedIds: Record; +} + +export function validateRow( + stepKey: ImportStepKey, + fields: Record, + lookups: ImportLookups, + batchState?: ImportBatchState, +): ValidatedRow { + const errors: string[] = []; + let action: ImportRowAction | null = null; + let matchKey: string | null = null; + let targetId: number | null = null; + const normalized: Record = { ...fields }; + const resolvedIds: Record = {}; + + if (stepKey === 'students') { + const name = textValue(fields.name); + if (!name) errors.push('姓名不能为空'); + normalized.name = name; + const phone = textValue(fields.phone); + if (phone && !PHONE_RE.test(phone)) errors.push('手机号格式不正确'); + normalized.phone = phone; + const genderRaw = textValue(fields.gender); + let gender = genderRaw; + if (genderRaw === '男' || genderRaw === '男性') gender = 'male'; + if (genderRaw === '女' || genderRaw === '女性') gender = 'female'; + if (genderRaw && !['male', 'female', '男', '女'].includes(genderRaw)) { + errors.push('性别只能是男/女'); + } + normalized.gender = gender; + const statusRaw = textValue(fields.status); + if (statusRaw && !['active', 'inactive', 'archived'].includes(statusRaw)) { + errors.push('状态只能是 active/inactive/archived'); + } + normalized.status = statusRaw || 'active'; + normalized.studentNo = textValue(fields.studentNo); + normalized.idNumber = textValue(fields.idNumber); + normalized.ethnicity = textValue(fields.ethnicity); + normalized.emergencyContact = textValue(fields.emergencyContact); + normalized.emergencyPhone = textValue(fields.emergencyPhone); + const orgName = textValue(fields.organization); + normalized.organization = orgName; + if (orgName) { + const org = lookups.organizations.get(orgName); + if (!org) errors.push(`未找到校区:${orgName}`); + else resolvedIds._organizationId = org.id; + } + const studentNo = textValue(fields.studentNo); + let matched = studentNo ? lookups.studentsByNo.get(studentNo) : undefined; + if (!matched && phone) matched = lookups.studentsByPhone.get(phone); + if (matched) { + action = 'update'; + targetId = matched.id; + matchKey = + studentNo && lookups.studentsByNo.get(studentNo) === matched + ? studentNo + : phone || studentNo || null; + } else { + action = 'create'; + matchKey = studentNo || phone || null; + } + } + + if (stepKey === 'rooms') { + const roomNumber = textValue(fields.roomNumber); + if (!roomNumber) errors.push('宿舍号不能为空'); + normalized.roomNumber = roomNumber; + normalized.building = textValue(fields.building); + normalized.roomType = textValue(fields.roomType); + const capacity = Number(fields.capacity); + if (!Number.isInteger(capacity) || capacity <= 0 || capacity > 999) { + errors.push('容量必须是 1-999 的整数'); + } + normalized.capacity = capacity; + if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') { + const floor = Number(fields.floor); + if (!Number.isInteger(floor) || floor < 0) errors.push('楼层必须是大于等于 0 的整数'); + normalized.floor = floor; + } + const rentalCategory = textValue(fields.rentalCategory); + if (rentalCategory && !['short', 'long'].includes(rentalCategory)) { + errors.push('租期类型只能是 short/long'); + } + normalized.rentalCategory = rentalCategory || 'short'; + const monthlyRate = Number(fields.monthlyRate ?? 0); + if (!Number.isFinite(monthlyRate) || monthlyRate < 0) + errors.push('月租必须是大于等于 0 的数字'); + normalized.monthlyRate = monthlyRate; + const matched = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined; + if (matched) { + action = 'update'; + targetId = matched.id; + matchKey = roomNumber; + } else { + action = 'create'; + matchKey = roomNumber || null; + } + } + + if (stepKey === 'checkins' || stepKey === 'transfers') { + const studentNo = textValue(fields.studentNo); + const phone = textValue(fields.phone); + const name = textValue(fields.name); + let student: Student | undefined; + if (studentNo) { + student = lookups.studentsByNo.get(studentNo); + if (!student && phone) student = lookups.studentsByPhone.get(phone); + if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对学号/手机号'); + } else if (phone) { + student = lookups.studentsByPhone.get(phone); + if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对手机号'); + } else { + errors.push('缺少学生标识:请映射“学号”或“手机号”'); + } + if (student && name && student.name !== name) { + errors.push(`姓名与手机号不匹配(档案姓名:${student.name})`); + } + if (student) resolvedIds._studentId = student.id; + const roomNumber = textValue(fields.roomNumber); + const room = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined; + if (stepKey === 'checkins') { + if (!roomNumber) errors.push('宿舍号不能为空'); + if (roomNumber && !room) { + errors.push('未找到宿舍:请先完成“宿舍档案”阶段,或核对宿舍号'); + } + const checkInDate = parseDateValue(fields.checkInDate); + if (!checkInDate) errors.push('入住日期格式不正确(应为 YYYY-MM-DD)'); + normalized.checkInDate = checkInDate; + normalized.stayType = textValue(fields.stayType) || 'short'; + if (student) { + const active = lookups.activeOccupancies.get(student.id) ?? []; + const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false; + const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false; + if (batchCheckedIn || batchTransferred) { + errors.push('该学生本次文件中已有在住记录,请勿重复导入'); + } else if (active.some((o) => o.roomId === room?.id)) { + errors.push('该学生已有该宿舍的在住记录'); + } else if (active.length > 0) { + errors.push('该学生已有在住记录:如需换宿请使用“换宿记录”阶段'); + } + } + if (errors.length === 0 && student && room) { + action = 'create'; + matchKey = `${student.id}|${room.id}`; + resolvedIds._roomId = room.id; + } + } else { + const oldRoomNumber = textValue(fields.oldRoom); + const newRoomNumber = textValue(fields.newRoom); + const oldRoom = oldRoomNumber ? lookups.roomsByNumber.get(oldRoomNumber) : undefined; + const newRoom = newRoomNumber ? lookups.roomsByNumber.get(newRoomNumber) : undefined; + if (!oldRoomNumber) errors.push('原宿舍不能为空'); + if (oldRoomNumber && !oldRoom) { + errors.push('未找到原宿舍:请先完成“宿舍档案”阶段,或核对宿舍号'); + } + if (!newRoomNumber) errors.push('新宿舍不能为空'); + if (newRoomNumber && !newRoom) { + errors.push('未找到新宿舍:请先完成“宿舍档案”阶段,或核对宿舍号'); + } + if (oldRoom && newRoom && oldRoom.id === newRoom.id) errors.push('原宿舍和新宿舍不能相同'); + const transferDate = parseDateValue(fields.transferDate); + if (!transferDate) errors.push('换宿日期格式不正确(应为 YYYY-MM-DD)'); + normalized.transferDate = transferDate; + normalized.reason = textValue(fields.reason); + if (student) { + const active = lookups.activeOccupancies.get(student.id) ?? []; + const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false; + const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false; + const oldOccupancy = active.find((o) => o.roomId === oldRoom?.id); + if (batchTransferred) { + errors.push('该学生本次文件中已有换宿记录,请勿重复换宿'); + } else if (batchCheckedIn) { + errors.push('该学生的入住记录来自本次文件,请先提交入住阶段后再换宿'); + } else if (!oldOccupancy) { + errors.push('未找到该学生在原宿舍的在住记录:请先完成“入住记录”阶段'); + } else { + targetId = oldOccupancy.id; + } + } + if (errors.length === 0 && student && oldRoom && newRoom) { + action = 'create'; + matchKey = `${student.id}|${oldRoom.id}->${newRoom.id}`; + resolvedIds._newRoomId = newRoom.id; + } + } + } + + return { errors, action, matchKey, targetId, normalized, resolvedIds }; +} + +export async function writeRow( + manager: EntityManager, + stepKey: ImportStepKey, + action: ImportRowAction, + fields: Record, + targetId: number | null, +): Promise { + if (stepKey === 'students') { + const studentRepo = manager.getRepository(Student); + if (action === 'create') { + const student = studentRepo.create({ + name: textValue(fields.name), + studentNo: textValue(fields.studentNo) || undefined, + phone: textValue(fields.phone) || undefined, + idNumber: textValue(fields.idNumber) || undefined, + gender: textValue(fields.gender) || undefined, + ethnicity: textValue(fields.ethnicity) || undefined, + emergencyContact: textValue(fields.emergencyContact) || undefined, + emergencyPhone: textValue(fields.emergencyPhone) || undefined, + status: textValue(fields.status) || 'active', + organizationId: optionalNumber(fields._organizationId) ?? undefined, + }); + await studentRepo.save(student); + return student.id; + } + if (!targetId) throw new Error('缺少待更新学生记录'); + const student = await studentRepo.findOneBy({ id: targetId }); + if (!student) throw new Error('待更新的学生记录不存在'); + applyString(student, 'name', fields.name); + applyString(student, 'studentNo', fields.studentNo); + applyString(student, 'phone', fields.phone); + applyString(student, 'idNumber', fields.idNumber); + applyString(student, 'gender', fields.gender); + applyString(student, 'ethnicity', fields.ethnicity); + applyString(student, 'emergencyContact', fields.emergencyContact); + applyString(student, 'emergencyPhone', fields.emergencyPhone); + applyString(student, 'status', fields.status); + const orgId = optionalNumber(fields._organizationId); + if (orgId !== null) { + student.organizationId = orgId; + } + await studentRepo.save(student); + return student.id; + } + + if (stepKey === 'rooms') { + const roomRepo = manager.getRepository(Room); + if (action === 'create') { + const room = roomRepo.create({ + roomNumber: textValue(fields.roomNumber), + building: textValue(fields.building) || undefined, + floor: optionalNumber(fields.floor) ?? undefined, + capacity: Number(fields.capacity), + status: 'available', + roomType: textValue(fields.roomType) || undefined, + rentalCategory: textValue(fields.rentalCategory) || 'short', + monthlyRate: Number(fields.monthlyRate ?? 0), + }); + await roomRepo.save(room); + return room.id; + } + if (!targetId) throw new Error('缺少待更新宿舍记录'); + const room = await roomRepo.findOneBy({ id: targetId }); + if (!room) throw new Error('待更新的宿舍记录不存在'); + applyString(room, 'roomNumber', fields.roomNumber); + applyString(room, 'building', fields.building); + applyString(room, 'roomType', fields.roomType); + applyString(room, 'rentalCategory', fields.rentalCategory); + if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') { + room.floor = Number(fields.floor); + } + if (fields.capacity !== null && fields.capacity !== undefined && fields.capacity !== '') { + room.capacity = Number(fields.capacity); + } + if ( + fields.monthlyRate !== null && + fields.monthlyRate !== undefined && + fields.monthlyRate !== '' + ) { + room.monthlyRate = Number(fields.monthlyRate); + } + await roomRepo.save(room); + return room.id; + } + + if (stepKey === 'checkins') { + const studentId = optionalNumber(fields._studentId); + const roomId = optionalNumber(fields._roomId); + if (!studentId || !roomId) throw new Error('缺少学生或宿舍 ID'); + const occupancy = manager.getRepository(Occupancy).create({ + studentId, + roomId, + checkInDate: String(fields.checkInDate), + billingStartDate: String(fields.checkInDate), + status: 'active', + stayType: textValue(fields.stayType) || 'short', + }); + await manager.save(Occupancy, occupancy); + return occupancy.id; + } + + if (stepKey === 'transfers') { + if (!targetId) throw new Error('缺少原入住记录'); + const studentId = optionalNumber(fields._studentId); + const newRoomId = optionalNumber(fields._newRoomId); + if (!studentId || !newRoomId) throw new Error('缺少学生或新宿舍 ID'); + const oldOccupancy = await manager.getRepository(Occupancy).findOneBy({ id: targetId }); + if (!oldOccupancy) throw new Error('原入住记录不存在'); + oldOccupancy.checkOutDate = String(fields.transferDate); + oldOccupancy.status = 'archived'; + await manager.save(Occupancy, oldOccupancy); + const newOccupancy = manager.getRepository(Occupancy).create({ + studentId, + roomId: newRoomId, + checkInDate: String(fields.transferDate), + billingStartDate: String(fields.transferDate), + status: 'active', + stayType: oldOccupancy.stayType, + }); + await manager.save(Occupancy, newOccupancy); + return newOccupancy.id; + } + return null; +} diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts new file mode 100644 index 0000000..9d341cb --- /dev/null +++ b/apps/server/src/imports/imports.run.service.ts @@ -0,0 +1,170 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { Repository } from 'typeorm'; +import * as ExcelJS from 'exceljs'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { + IMPORT_STEP_LABELS, + IMPORT_STEP_ORDER, +} from './imports.types'; +import type { + CellValue, + ColumnMapping, + ImportRunSource, + ImportStageRequest, + ImportStepKey, + ParsedImportFile, + StepPreviewSummary, +} from './imports.types'; +import { parseJson } from './imports.helpers'; +import { extractSheets } from './imports.workbook'; +import type { ImportSheetData } from './imports.workbook'; +import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping'; +import { findOwnedRun } from './imports.access'; +import type { ImportPrincipal } from './imports.access'; + +@Injectable() +export class ImportRunService { + constructor( + @InjectRepository(ImportRun) + private readonly runs: Repository, + @InjectRepository(ImportStep) + private readonly steps: Repository, + ) {} + + async createRun( + principal: ImportPrincipal, + source: ImportRunSource, + file: ParsedImportFile, + conversationId?: number | null, + stages?: ImportStageRequest[], + mappingByStep?: Partial>, + ) { + if (!file.buffer || file.buffer.length === 0) { + throw new BadRequestException('上传文件为空'); + } + const isCsv = + /\.csv$/i.test(file.originalName) || + /csv/i.test(file.mimeType) || + /text\/(csv|plain)/i.test(file.mimeType); + const isXlsx = + /\.xlsx$/i.test(file.originalName) || + /spreadsheetml/i.test(file.mimeType) || + /excel/i.test(file.mimeType); + if (!isCsv && !isXlsx) { + throw new BadRequestException('仅支持 .xlsx / .csv 文件'); + } + if (/\.xls$/i.test(file.originalName) && !/\.xlsx$/i.test(file.originalName)) { + throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试'); + } + + let sheets: ImportSheetData[]; + try { + const workbook = new ExcelJS.Workbook(); + if (isCsv) { + await workbook.csv.read(Readable.from(Buffer.from(file.buffer))); + } else { + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + } + sheets = extractSheets(workbook); + } catch { + throw new BadRequestException('Excel 文件解析失败,请检查文件格式'); + } + if (!sheets.length) { + throw new BadRequestException('文件中没有可用的工作表数据'); + } + + const runId = randomUUID(); + const run = this.runs.create({ + id: runId, + userId: principal.id, + conversationId: conversationId ?? null, + source, + fileName: file.originalName.slice(0, 255), + sheetsJson: JSON.stringify(sheets), + status: 'ready', + currentStepKey: null, + error: null, + }); + + const stepRecords: ImportStep[] = []; + for (const stepKey of IMPORT_STEP_ORDER) { + const assigned = + stages && stages.length > 0 + ? resolveAssignedSheets( + stages, + stepKey, + sheets.map((s) => s.name), + ) + : autoAssignedSheets(sheets, stepKey); + if (assigned.length === 0) { + stepRecords.push( + this.steps.create({ + runId, + stepKey, + sheetsJson: '[]', + mappingJson: null, + status: 'skipped', + summaryJson: null, + committedAt: null, + }), + ); + continue; + } + const firstSheet = sheets.find((s) => s.name === assigned[0]); + const mapping = + mappingByStep?.[stepKey] ?? suggestMapping(firstSheet?.headers ?? [], stepKey); + stepRecords.push( + this.steps.create({ + runId, + stepKey, + sheetsJson: JSON.stringify(assigned), + mappingJson: mapping ? JSON.stringify(mapping) : null, + status: 'pending', + summaryJson: null, + committedAt: null, + }), + ); + } + const firstActive = stepRecords.find((s) => s.status !== 'skipped'); + run.currentStepKey = firstActive?.stepKey ?? null; + await this.runs.save(run); + await this.steps.save(stepRecords); + return this.getRun(principal.id, runId); + } + + async getRun(userId: number, runId: string) { + const run = await findOwnedRun(this.runs, userId, runId); + const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } }); + const sheets = + parseJson>(run.sheetsJson) ?? + []; + return { + id: run.id, + fileName: run.fileName, + source: run.source, + status: run.status, + currentStepKey: run.currentStepKey, + createdAt: run.createdAt.toISOString(), + sheets: sheets.map((sheet) => ({ + name: sheet.name, + headers: sheet.headers, + rowCount: sheet.rows.length, + suggestedStepKey: suggestStep(sheet.headers)?.stepKey ?? null, + })), + steps: stepRecords.map((step) => ({ + id: step.id, + stepKey: step.stepKey, + label: IMPORT_STEP_LABELS[step.stepKey], + sheets: parseJson(step.sheetsJson) ?? [], + status: step.status, + mapping: parseJson(step.mappingJson) ?? {}, + summary: parseJson(step.summaryJson), + committedAt: step.committedAt?.toISOString() ?? null, + })), + }; + } +} diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts new file mode 100644 index 0000000..bdf9ba9 --- /dev/null +++ b/apps/server/src/imports/imports.service.spec.ts @@ -0,0 +1,606 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; +import { Organization } from '../entities/organization.entity'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { ImportRow } from './entities/import-row.entity'; +import { ImportsService } from './imports.service'; +import type { ParsedImportFile } from './imports.types'; + +function makeRowsRepo() { + let nextId = 1; + return { + create: jest.fn((value: unknown) => value), + save: jest.fn(async (rows: unknown[]) => { + const list = Array.isArray(rows) ? rows : [rows]; + for (const row of list) { + const record = row as { id?: number }; + if (record.id === undefined) record.id = nextId++; + } + return list; + }), + delete: jest.fn().mockResolvedValue({ affected: 0 }), + find: jest.fn().mockResolvedValue([]), + }; +} + +function makeStepsRepo(step: ImportStep) { + return { + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + findOne: jest.fn().mockResolvedValue(step), + find: jest.fn().mockResolvedValue([]), + }; +} + +function makeRunsRepo(run: ImportRun) { + return { + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + findOne: jest.fn().mockResolvedValue(run), + }; +} + +function studentSheet() { + return { + name: '学生', + headers: ['姓名', '学号', '手机号'], + rows: [['张三', '2024001', '13800138000']], + }; +} + +async function xlsxBuffer(sheet: { + name: string; + headers: string[]; + rows: unknown[][]; +}): Promise { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet(sheet.name); + ws.addRow(sheet.headers); + for (const row of sheet.rows) ws.addRow(row); + return (await workbook.xlsx.writeBuffer()) as Buffer; +} + +function fileOf(name: string, buffer: Buffer): ParsedImportFile { + return { + originalName: name, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size: buffer.length, + buffer, + }; +} + +const principal = { + id: 7, + permissions: ['student:import', 'room:create', 'occupancy:checkin'], + isSuperAdmin: false, +}; + +describe('ImportsService', () => { + it('拒绝 .xls 文件', async () => { + const service = new ImportsService( + makeRunsRepo({} as ImportRun) as never, + makeStepsRepo({} as ImportStep) as never, + makeRowsRepo() as never, + {} as never, + ); + await expect( + service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))), + ).rejects.toThrow('暂不支持 .xls'); + }); + + it('上传学生表时自动分配 students 阶段并返回运行详情', async () => { + const buffer = await xlsxBuffer(studentSheet()); + const run = { + id: 'run-1', + userId: 7, + conversationId: null, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: JSON.stringify([studentSheet()]), + status: 'ready', + currentStepKey: 'students', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const steps = [ + { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}', + status: 'pending', + }, + { + id: 2, + runId: 'run-1', + stepKey: 'rooms', + sheetsJson: '[]', + mappingJson: null, + status: 'skipped', + }, + { + id: 3, + runId: 'run-1', + stepKey: 'checkins', + sheetsJson: '[]', + mappingJson: null, + status: 'skipped', + }, + { + id: 4, + runId: 'run-1', + stepKey: 'transfers', + sheetsJson: '[]', + mappingJson: null, + status: 'skipped', + }, + ] as unknown as ImportStep[]; + const runsRepo = makeRunsRepo(run); + const stepsRepo = { + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue(steps), + }; + const service = new ImportsService( + runsRepo as never, + stepsRepo as never, + makeRowsRepo() as never, + {} as never, + ); + + const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer)); + expect(detail.currentStepKey).toBe('students'); + expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']); + expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({ + name: '姓名', + studentNo: '学号', + phone: '手机号', + }); + }); + + it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const run = { + id: 'run-1', + userId: 7, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: JSON.stringify([studentSheet()]), + status: 'ready', + currentStepKey: 'students', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-1', 'students', { + sheets: ['学生'], + mapping: { name: '姓名', studentNo: '学号', phone: '手机号' }, + }); + expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 }); + expect(result.rows[0].action).toBe('update'); + expect(result.rows[0].status).toBe('valid'); + expect(result.rows[0].id).toBeDefined(); + }); + + it('预览入住阶段:宿舍不存在时按依赖错误提示', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const run = { + id: 'run-2', + userId: 7, + source: 'manual', + fileName: 'checkins.xlsx', + sheetsJson: JSON.stringify([ + { + name: '入住', + headers: ['姓名', '手机号', '宿舍号', '入住日期'], + rows: [['张三', '13800138000', 'A101', '2026-09-01']], + }, + ]), + status: 'ready', + currentStepKey: 'checkins', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 3, + runId: 'run-2', + stepKey: 'checkins', + sheetsJson: '["入住"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-2', 'checkins', { + sheets: ['入住'], + mapping: { + name: '姓名', + phone: '手机号', + roomNumber: '宿舍号', + checkInDate: '入住日期', + }, + }); + expect(result.summary).toMatchObject({ total: 1, valid: 0, error: 1 }); + expect(result.rows[0].status).toBe('error'); + expect(result.rows[0].errors.join(';')).toContain('未找到宿舍'); + }); + + it('提交阶段需要对应权限;已完成的任务幂等返回回执', async () => { + const run = { + id: 'run-1', + userId: 7, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: '[]', + status: 'ready', + currentStepKey: 'transfers', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 4, + runId: 'run-1', + stepKey: 'transfers', + sheetsJson: '["换宿"]', + mappingJson: '{}', + status: 'ready', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + rowsRepo.find.mockResolvedValue([]); + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + {} as never, + ); + await expect( + service.commitStep( + { id: 7, permissions: ['student:import'], isSuperAdmin: false }, + 'run-1', + 'transfers', + [], + ), + ).rejects.toBeInstanceOf(ForbiddenException); + + const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun; + const committedStep = { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: '{}', + status: 'committed', + summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }), + } as ImportStep; + const committedService = new ImportsService( + makeRunsRepo(committedRun) as never, + makeStepsRepo(committedStep) as never, + makeRowsRepo() as never, + {} as never, + ); + const receipt = await committedService.commitStep(principal, 'run-1', 'students', []); + expect(receipt.status).toBe('already_committed'); + expect(receipt.created).toBe(1); + }); + + it('提交阶段拒绝与预览分类矛盾的决策', async () => { + const run = { + id: 'run-1', + userId: 7, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: '[]', + status: 'ready', + currentStepKey: 'students', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: '{}', + status: 'ready', + } as ImportStep; + const row = { + id: 11, + runId: 'run-1', + stepId: 1, + sheetName: '学生', + rowNumber: 3, + rawJson: '{}', + normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }), + matchKey: '2024001', + action: 'update', + status: 'valid', + errorsJson: null, + targetId: 88, + } as ImportRow; + const rowsRepo = makeRowsRepo(); + rowsRepo.find.mockResolvedValue([row]); + const dataSource = { + transaction: jest.fn(), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + await expect( + service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]), + ).rejects.toThrow('预览判定为「更新」'); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('预览学生阶段:学号未命中时回退到手机号匹配', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const run = { + id: 'run-1', + userId: 7, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: JSON.stringify([ + { + name: '学生', + headers: ['姓名', '学号', '手机号'], + rows: [['张三', '2024999', '13800138000']], + }, + ]), + status: 'ready', + currentStepKey: 'students', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-1', 'students', { + sheets: ['学生'], + mapping: { name: '姓名', studentNo: '学号', phone: '手机号' }, + }); + expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 }); + expect(result.rows[0].action).toBe('update'); + expect(result.rows[0].status).toBe('valid'); + expect(result.rows[0].id).toBeDefined(); + }); + + it('预览入住阶段:同一文件内重复入住标记为错误', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const room = { id: 5, roomNumber: 'A101' } as Room; + const run = { + id: 'run-2', + userId: 7, + source: 'manual', + fileName: 'checkins.xlsx', + sheetsJson: JSON.stringify([ + { + name: '入住', + headers: ['姓名', '手机号', '宿舍号', '入住日期'], + rows: [ + ['张三', '13800138000', 'A101', '2026-09-01'], + ['张三', '13800138000', 'A101', '2026-09-02'], + ], + }, + ]), + status: 'ready', + currentStepKey: 'checkins', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 3, + runId: 'run-2', + stepKey: 'checkins', + sheetsJson: '["入住"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-2', 'checkins', { + sheets: ['入住'], + mapping: { + name: '姓名', + phone: '手机号', + roomNumber: '宿舍号', + checkInDate: '入住日期', + }, + }); + expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 }); + expect(result.rows[0].status).toBe('valid'); + expect(result.rows[1].status).toBe('error'); + expect(result.rows[1].errors.join(';')).toContain('请勿重复导入'); + }); + + it('预览换宿阶段:同一文件内重复换宿标记为错误', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const oldRoom = { id: 5, roomNumber: 'A101' } as Room; + const newRoom = { id: 6, roomNumber: 'B202' } as Room; + const run = { + id: 'run-3', + userId: 7, + source: 'manual', + fileName: 'transfers.xlsx', + sheetsJson: JSON.stringify([ + { + name: '换宿', + headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'], + rows: [ + ['张三', '13800138000', 'A101', 'B202', '2026-09-10'], + ['张三', '13800138000', 'A101', 'B202', '2026-09-11'], + ], + }, + ]), + status: 'ready', + currentStepKey: 'transfers', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 4, + runId: 'run-3', + stepKey: 'transfers', + sheetsJson: '["换宿"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([oldRoom, newRoom]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) { + return { + find: jest + .fn() + .mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]), + }; + } + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-3', 'transfers', { + sheets: ['换宿'], + mapping: { + name: '姓名', + phone: '手机号', + oldRoom: '原宿舍', + newRoom: '新宿舍', + transferDate: '换宿日期', + }, + }); + expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 }); + expect(result.rows[0].status).toBe('valid'); + expect(result.rows[1].status).toBe('error'); + expect(result.rows[1].errors.join(';')).toContain('请勿重复换宿'); + }); +}); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts new file mode 100644 index 0000000..e50ac90 --- /dev/null +++ b/apps/server/src/imports/imports.service.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { ImportRun } from './entities/import-run.entity'; +import { ImportStep } from './entities/import-step.entity'; +import { ImportRow } from './entities/import-row.entity'; +import { ImportRunService } from './imports.run.service'; +import { ImportPreviewService } from './imports.preview.service'; +import { ImportCommitService } from './imports.commit.service'; + +export type { + ImportSheetMeta, + ImportStepDetail, + ImportRunDetail, + StepPreviewResult, +} from './imports.types'; + +@Injectable() +export class ImportsService { + private runService?: ImportRunService; + private previewService?: ImportPreviewService; + private commitService?: ImportCommitService; + + constructor( + @InjectRepository(ImportRun) + private readonly runs: Repository, + @InjectRepository(ImportStep) + private readonly steps: Repository, + @InjectRepository(ImportRow) + private readonly rows: Repository, + private readonly dataSource: DataSource, + ) {} + + private get runsSvc(): ImportRunService { + if (!this.runService) { + this.runService = new ImportRunService(this.runs, this.steps); + } + return this.runService; + } + + private get previews(): ImportPreviewService { + if (!this.previewService) { + this.previewService = new ImportPreviewService( + this.runs, + this.steps, + this.rows, + this.dataSource, + ); + } + return this.previewService; + } + + private get commits(): ImportCommitService { + if (!this.commitService) { + this.commitService = new ImportCommitService( + this.runs, + this.steps, + this.rows, + this.dataSource, + ); + } + return this.commitService; + } + + async createRun(...args: Parameters) { + return this.runsSvc.createRun(...args); + } + + async getRun(...args: Parameters) { + return this.runsSvc.getRun(...args); + } + + async previewStep(...args: Parameters) { + return this.previews.previewStep(...args); + } + + async commitStep(...args: Parameters) { + return this.commits.commitStep(...args); + } + + async errorReport(...args: Parameters) { + return this.commits.errorReport(...args); + } +} diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts new file mode 100644 index 0000000..8eeee84 --- /dev/null +++ b/apps/server/src/imports/imports.types.ts @@ -0,0 +1,210 @@ +/** + * Unified Excel batch-import workflow (v1). + * + * Staged by business dependency: + * students / rooms (基础档案) → checkins / transfers (关系) + * Each stage is previewed, confirmed and committed separately. + */ + +export const IMPORT_STEP_KEYS = ['students', 'rooms', 'checkins', 'transfers'] as const; +export type ImportStepKey = (typeof IMPORT_STEP_KEYS)[number]; + +export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = [ + 'students', + 'rooms', + 'checkins', + 'transfers', +]; + +export type CellValue = string | number | boolean | Date | null; + +export type ImportRunSource = 'ai' | 'manual'; + +export type ImportRunStatus = + | 'preparing' + | 'ready' + | 'committing' + | 'committed' + | 'failed' + | 'expired'; + +export type ImportStepStatus = + | 'pending' + | 'ready' + | 'committing' + | 'committed' + | 'failed' + | 'skipped'; + +export type ImportRowStatus = 'pending' | 'valid' | 'error' | 'committed' | 'skipped'; + +export type ImportRowAction = 'create' | 'update' | 'skip'; + +/** field -> sheet header name */ +export type ColumnMapping = Record; + +// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致 +export interface ImportStageRequest { + stepKey: ImportStepKey; + sheet?: string; + headerRow?: number; +} + +export interface ImportStageSuggestion { + stepKey: ImportStepKey; + mapping: ColumnMapping; + matchedFields: number; +} + +export interface ParsedImportFile { + originalName: string; + mimeType: string; + size: number; + buffer: Buffer; +} + +export interface ImportRowDecision { + rowId: number; + action: ImportRowAction; +} + +export interface StepPreviewRow { + id: number; + rowNumber: number; + sheetName: string; + raw: Record; + fields: Record; + action: ImportRowAction | null; + status: ImportRowStatus; + errors: string[]; +} + +export interface StepPreviewSummary { + total: number; + valid: number; + error: number; + create: number; + update: number; + skip: number; +} + +export interface StepCommitReceipt { + runId: string; + stepKey: ImportStepKey; + status: 'committed' | 'already_committed' | 'conflict'; + created: number; + updated: number; + skipped: number; + failed: number; + total: number; + nextStepKey: ImportStepKey | null; + runStatus: ImportRunStatus; + message: string; +} + +// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致 +export interface ImportSheetMeta { + name: string; + headers: string[]; + rowCount: number; + suggestedStepKey: ImportStepKey | null; +} + +export interface ImportStepDetail { + id: number; + stepKey: ImportStepKey; + label: string; + sheets: string[]; + status: import('./entities/import-step.entity').ImportStep['status']; + mapping: ColumnMapping; + summary: StepPreviewSummary | null; + committedAt: string | null; +} + +export interface ImportRunDetail { + id: string; + fileName: string; + source: ImportRunSource; + status: import('./entities/import-run.entity').ImportRun['status']; + currentStepKey: ImportStepKey | null; + createdAt: string; + sheets: ImportSheetMeta[]; + steps: ImportStepDetail[]; +} + +export interface StepPreviewResult { + stepKey: ImportStepKey; + sheetNames: string[]; + headers: string[]; + mapping: ColumnMapping; + rows: StepPreviewRow[]; + summary: StepPreviewSummary; +} + +export const IMPORT_ACTION_LABELS: Record = { + create: '新建', + update: '更新', + skip: '跳过', +}; + +/** Field alias tables used to auto-suggest column mappings. */ +export const IMPORT_FIELD_ALIASES: Record> = { + students: { + name: ['姓名', '名字', '学生姓名', '学生名字'], + studentNo: ['学号', '学生学号', '编号'], + phone: ['手机号', '手机号码', '联系电话', '电话'], + gender: ['性别'], + idNumber: ['身份证', '身份证号', '身份证号码'], + ethnicity: ['民族'], + emergencyContact: ['紧急联系人'], + emergencyPhone: ['紧急联系电话', '紧急电话'], + organization: ['校区', '机构', '组织', '校区名称'], + status: ['状态'], + }, + rooms: { + roomNumber: ['宿舍号', '房间号', '房号', '宿舍编号'], + building: ['楼栋', '楼', '栋'], + floor: ['楼层'], + capacity: ['容量', '床位数', '人数'], + roomType: ['房型', '房间类型', '宿舍类型'], + rentalCategory: ['租期', '租期类型'], + monthlyRate: ['月租', '月租金', '租金'], + }, + checkins: { + name: ['姓名', '学生姓名', '名字'], + studentNo: ['学号', '学生学号'], + phone: ['手机号', '手机号码', '电话'], + roomNumber: ['宿舍号', '房间号', '房号'], + checkInDate: ['入住日期', '入住时间', '日期'], + stayType: ['住宿类型', '类型'], + }, + transfers: { + studentNo: ['学号', '学生学号'], + phone: ['手机号', '手机号码'], + oldRoom: ['原宿舍', '原房间', '原宿舍号', '旧宿舍', '旧房间'], + newRoom: ['新宿舍', '新房间', '新宿舍号'], + transferDate: ['换宿日期', '变更日期', '日期'], + reason: ['原因', '备注', '换宿原因'], + }, +}; + +export const IMPORT_STEP_LABELS: Record = { + students: '学生档案', + rooms: '宿舍档案', + checkins: '入住记录', + transfers: '换宿记录', +}; + +export const IMPORT_STEP_REQUIRED_FIELDS: Record = { + students: ['name'], + rooms: ['roomNumber', 'capacity'], + checkins: ['roomNumber', 'checkInDate'], + transfers: ['oldRoom', 'newRoom', 'transferDate'], +}; + +export const IMPORT_STEP_IDENTITY_FIELDS: Record = { + students: ['studentNo', 'phone'], + rooms: ['roomNumber'], + checkins: ['studentNo', 'phone'], + transfers: ['studentNo', 'phone'], +}; diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts new file mode 100644 index 0000000..9a5ac41 --- /dev/null +++ b/apps/server/src/imports/imports.workbook.ts @@ -0,0 +1,39 @@ +import * as ExcelJS from 'exceljs'; +import { cellValue, textValue } from './imports.helpers'; +import type { CellValue } from './imports.types'; + +const MAX_SHEETS = 30; +const MAX_ROWS_PER_SHEET = 3000; +const MAX_COLS_PER_SHEET = 60; + +export interface ImportSheetData { + name: string; + headers: string[]; + rows: CellValue[][]; +} + +export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] { + const sheets: ImportSheetData[] = []; + for (const worksheet of workbook.worksheets) { + if (sheets.length >= MAX_SHEETS) break; + const headers: string[] = []; + const rows: CellValue[][] = []; + const firstRow = worksheet.getRow(1); + for (let col = 1; col <= Math.min(firstRow.cellCount, MAX_COLS_PER_SHEET); col += 1) { + const header = textValue(cellValue(firstRow.getCell(col))); + headers.push(header); + } + if (!headers.some(Boolean)) continue; + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber === 1 || rows.length >= MAX_ROWS_PER_SHEET) return; + const values: CellValue[] = []; + for (let col = 1; col <= headers.length; col += 1) { + values.push(cellValue(row.getCell(col))); + } + if (values.every((v) => v === null || textValue(v) === '')) return; + rows.push(values); + }); + if (rows.length > 0) sheets.push({ name: worksheet.name, headers, rows }); + } + return sheets; +} From 80e6fccf055629799ae333441e5d4c2d4736fd52 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:11:43 +0800 Subject: [PATCH 10/19] =?UTF-8?q?chore:=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=A3=85=E9=85=8D=E4=B8=8E=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 176 +++++++++++----------------- apps/server/src/main.ts | 6 +- apps/server/src/migration-runner.ts | 4 + 3 files changed, 78 insertions(+), 108 deletions(-) diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 1616962..c90aca7 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,64 +1,12 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; +import { LoggerModule } from 'nestjs-pino'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { ScheduleModule } from '@nestjs/schedule'; -import { - Student, - Room, - Occupancy, - Bed, - Locker, - RoomExpense, - PersonalExpense, - Bill, - BillItem, - User, - OperationLog, - RoomInspection, - RoomInspectionDetail, - Deposit, - DepositInstallment, - Classroom, - Organization, - ClassroomRental, - Permission, - Role, - Class, - ClassStudent, - ClassTeacher, - ClassSchedule, - AttendanceRecord, - AttendanceSession, - AttendanceDevice, - AttendancePeriodConfig, - DingAttendanceRaw, - SyncLog, - SyncState, - Notification, - StudentProfile, - StudentEnrollment, - ExamScore, - Exam, - LearningRecord, - ExpenseType, - ResultArchive, - ArchiveAttachment, - StudentDingMapping, - JinshujuMatchRule, - AiConfig, - StudentWallet, - WalletTransaction, - FinancialOperation, - AiConversation, - AiMessage, - AiToolRun, - AiAttachment, - AiForm, - AiReview, -} from './entities'; +import * as Entities from './entities'; import { AuthModule } from './auth/auth.module'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; @@ -68,6 +16,8 @@ import { AddAiChat1784780000000 } from './migrations/1784780000000-AddAiChat'; import { EnhanceAiChatForAntDesignX1784860000000 } from './migrations/1784860000000-EnhanceAiChatForAntDesignX'; import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiForms'; import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews'; +import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; +import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; const allMigrations = [ InitialSchema1784520727860, AddExamManagement1784600000000, @@ -77,6 +27,8 @@ const allMigrations = [ EnhanceAiChatForAntDesignX1784860000000, AddA2UiForms1784870000000, AddA2UiReviews1784880000000, + AddImportRuns1784910000000, + DropAiMessageFeedback1784920000000, ]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; @@ -109,6 +61,7 @@ import { WalletsModule } from './wallets/wallets.module'; import { FinancialOperationsModule } from './financial-operations/financial-operations.module'; import { ExamsModule } from './exams/exams.module'; import { AiChatModule } from './ai-chat'; +import { ImportsModule } from './imports/imports.module'; import { IntegrationConfig, @@ -118,6 +71,12 @@ import { IntegrationConfigModule } from './integration/config/config.module'; @Module({ imports: [ + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.LOG_LEVEL ?? 'info', + autoLogging: process.env.NODE_ENV === 'production', + }, + }), AuthorizationModule, ConfigModule.forRoot({ isGlobal: true }), ThrottlerModule.forRoot([ @@ -134,61 +93,63 @@ import { IntegrationConfigModule } from './integration/config/config.module'; useFactory: (config: ConfigService): TypeOrmModuleOptions => { const dbType = config.get('DB_TYPE', 'sqlite'); const allEntities = [ - Student, - Room, - Occupancy, - Bed, - Locker, - RoomExpense, - PersonalExpense, - Bill, - BillItem, - User, - OperationLog, - RoomInspection, - RoomInspectionDetail, - Deposit, - DepositInstallment, - Classroom, - Organization, - ClassroomRental, - Class, - ClassStudent, - ClassTeacher, - Permission, - Role, - ClassSchedule, - AttendanceRecord, - AttendanceSession, - AttendanceDevice, - AttendancePeriodConfig, - DingAttendanceRaw, - Notification, - StudentProfile, - StudentEnrollment, - ExamScore, - Exam, - LearningRecord, - StudentDingMapping, - JinshujuMatchRule, - ExpenseType, - ArchiveAttachment, - ResultArchive, - SyncLog, - SyncState, - StudentDingMapping, + Entities.Student, + Entities.Room, + Entities.Occupancy, + Entities.Bed, + Entities.Locker, + Entities.RoomExpense, + Entities.PersonalExpense, + Entities.Bill, + Entities.BillItem, + Entities.User, + Entities.OperationLog, + Entities.RoomInspection, + Entities.RoomInspectionDetail, + Entities.Deposit, + Entities.DepositInstallment, + Entities.Classroom, + Entities.Organization, + Entities.ClassroomRental, + Entities.Class, + Entities.ClassStudent, + Entities.ClassTeacher, + Entities.Permission, + Entities.Role, + Entities.ClassSchedule, + Entities.AttendanceRecord, + Entities.AttendanceSession, + Entities.AttendanceDevice, + Entities.AttendancePeriodConfig, + Entities.DingAttendanceRaw, + Entities.Notification, + Entities.StudentProfile, + Entities.StudentEnrollment, + Entities.ExamScore, + Entities.Exam, + Entities.LearningRecord, + Entities.StudentDingMapping, + Entities.JinshujuMatchRule, + Entities.ExpenseType, + Entities.ArchiveAttachment, + Entities.ResultArchive, + Entities.SyncLog, + Entities.SyncState, IntegrationConfig, IntegrationConfigDetail, - AiConfig, - StudentWallet, - WalletTransaction, - FinancialOperation, - AiConversation, - AiMessage, - AiToolRun, - AiAttachment, - AiForm, - AiReview, + Entities.AiConfig, + Entities.StudentWallet, + Entities.WalletTransaction, + Entities.FinancialOperation, + Entities.AiConversation, + Entities.AiMessage, + Entities.AiToolRun, + Entities.AiAttachment, + Entities.AiForm, + Entities.AiReview, + Entities.ImportRun, + Entities.ImportStep, + Entities.ImportRow, ]; if (dbType === 'mysql') { return { @@ -242,6 +203,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; ExpenseTypesModule, AiConfigModule, AiChatModule, + ImportsModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 35946fd..1f58ab6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,4 +1,6 @@ import { NestFactory } from '@nestjs/core'; +import helmet from 'helmet'; +import compression from 'compression'; import { AppModule } from './app.module'; import { runMigrationsOnStartup } from './migration-runner'; @@ -8,7 +10,9 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.enableCors(); + app.use(helmet()); + app.use(compression()); + await app.listen(process.env.PORT ?? 3000); - console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`); } bootstrap(); diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index 8c6f56d..4e9da94 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -8,6 +8,8 @@ import { EnhanceAiChatForAntDesignX1784860000000 } from './migrations/1784860000 import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiForms'; import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews'; import { EnlargeAiReviewSections1784900000000 } from './migrations/1784900000000-EnlargeAiReviewSections'; +import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; +import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; import { config } from 'dotenv'; config(); @@ -36,6 +38,8 @@ export async function runMigrationsOnStartup(): Promise { AddA2UiForms1784870000000, AddA2UiReviews1784880000000, EnlargeAiReviewSections1784900000000, + AddImportRuns1784910000000, + DropAiMessageFeedback1784920000000, ], }); From fd39e1686ab6c03bd4a4e1d83c3ed74296c9112e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:12:00 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=90=84?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9D=97=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/JinshujuMatchModal.tsx | 542 +------ .../components/JinshujuMatchModal.types.ts | 58 + apps/admin/src/components/MatchSelector.tsx | 124 ++ apps/admin/src/components/MatchStep.tsx | 156 ++ apps/admin/src/components/RuleEditor.tsx | 123 ++ apps/admin/src/pages/Bills/bill-print.ts | 22 +- apps/admin/src/pages/Bills/index.tsx | 152 +- .../src/pages/Classes/ClassDetailTabs.tsx | 557 +++++++ apps/admin/src/pages/Classes/detail.tsx | 754 ++------- apps/admin/src/pages/Classes/index.tsx | 158 +- .../pages/ClassroomRentals/RentalTable.tsx | 340 ++++ .../src/pages/ClassroomRentals/index.tsx | 540 ++----- .../src/pages/ClassroomSchedule/index.tsx | 45 +- apps/admin/src/pages/Classrooms/index.tsx | 180 ++- .../src/pages/Dashboard/Dashboard.types.ts | 128 ++ .../src/pages/Dashboard/DashboardCharts.ts | 262 +++ .../pages/Dashboard/DashboardLazyCards.tsx | 85 + .../pages/Dashboard/DashboardTodoCards.tsx | 124 ++ apps/admin/src/pages/Dashboard/index.tsx | 808 ++-------- .../src/pages/Deposits/DepositModals.tsx | 423 +++++ .../admin/src/pages/Deposits/DepositTable.tsx | 148 ++ apps/admin/src/pages/Deposits/index.tsx | 776 +++------ apps/admin/src/pages/Exams/ExamFormModal.tsx | 34 +- apps/admin/src/pages/Exams/detail.tsx | 55 +- apps/admin/src/pages/Exams/index.tsx | 373 ++++- apps/admin/src/pages/Exams/style.css | 4 + .../src/pages/Expenses/ExpenseModals.tsx | 166 ++ .../src/pages/Expenses/ExpenseTablePanel.tsx | 524 ++++++ apps/admin/src/pages/Expenses/index.tsx | 1280 +++++---------- .../IntegrationOrgSyncPanel.tsx | 503 ++++++ .../src/pages/IntegrationConfig/index.tsx | 600 +------ apps/admin/src/pages/Notifications/index.tsx | 49 +- .../Occupancies/OccupanciesTableArea.tsx | 142 ++ .../pages/Occupancies/OccupanciesToolbar.tsx | 146 ++ .../pages/Occupancies/OccupancyColumns.tsx | 133 ++ .../src/pages/Occupancies/OccupancyModals.tsx | 551 +++++++ apps/admin/src/pages/Occupancies/index.tsx | 1239 ++++----------- .../Occupancies/useOccupancyMutations.ts | 65 + apps/admin/src/pages/Organizations/index.tsx | 251 +-- apps/admin/src/pages/RoomVisual/index.tsx | 57 +- apps/admin/src/pages/Rooms/RoomColumns.tsx | 323 ++++ apps/admin/src/pages/Rooms/RoomDrawer.tsx | 382 +++++ apps/admin/src/pages/Rooms/RoomModals.tsx | 244 +++ apps/admin/src/pages/Rooms/RoomsTable.tsx | 35 + apps/admin/src/pages/Rooms/RoomsToolbar.tsx | 198 +++ apps/admin/src/pages/Rooms/index.tsx | 1410 ++++------------- .../admin/src/pages/Rooms/useRoomMutations.ts | 163 ++ .../src/pages/Schedules/ScheduleGrids.tsx | 330 ++++ .../src/pages/Schedules/ScheduleModals.tsx | 561 +++++++ apps/admin/src/pages/Schedules/index.tsx | 1147 +++----------- .../src/pages/Students/StudentColumns.tsx | 388 +++++ .../src/pages/Students/StudentModals.tsx | 179 +++ .../src/pages/Students/StudentsTable.tsx | 138 ++ .../src/pages/Students/StudentsToolbar.tsx | 272 ++++ apps/admin/src/pages/Students/index.tsx | 1337 +++++----------- .../src/pages/TeacherWorkspace/index.tsx | 32 +- apps/admin/src/pages/Teachers/index.tsx | 93 +- apps/admin/src/pages/Wallets/index.tsx | 150 +- apps/server/src/bills/bills-export.service.ts | 10 +- .../src/bills/bills-generation.service.ts | 285 ++++ apps/server/src/bills/bills.controller.ts | 115 +- apps/server/src/bills/bills.module.ts | 3 +- .../src/bills/bills.purge.controller.spec.ts | 33 + apps/server/src/bills/bills.purge.spec.ts | 71 + apps/server/src/bills/bills.service.spec.ts | 16 +- apps/server/src/bills/bills.service.ts | 361 ++--- .../src/classes/classes-queries.service.ts | 172 ++ .../classes.batch-import-membership.spec.ts | 13 +- .../src/classes/classes.controller.spec.ts | 24 + apps/server/src/classes/classes.controller.ts | 123 +- apps/server/src/classes/classes.module.ts | 7 +- apps/server/src/classes/classes.purge.spec.ts | 54 + apps/server/src/classes/classes.service.ts | 225 +-- .../classroom-rentals.controller.ts | 99 +- .../classroom-rentals.module.ts | 14 +- ...classroom-rentals.purge.controller.spec.ts | 25 + .../classroom-rentals.purge.spec.ts | 92 ++ .../classroom-rentals.service.spec.ts | 32 +- .../classroom-rentals.service.ts | 432 +---- .../rental-schedule.service.ts | 341 ++++ .../src/classrooms/classrooms.controller.ts | 63 +- .../src/classrooms/classrooms.module.ts | 6 +- .../classrooms.purge.controller.spec.ts | 23 + .../src/classrooms/classrooms.purge.spec.ts | 59 + .../src/classrooms/classrooms.service.ts | 44 +- .../src/common/batch-restore.services.spec.ts | 26 +- .../dashboard/dashboard-queries.service.ts | 241 +++ apps/server/src/dashboard/dashboard.module.ts | 3 +- .../src/dashboard/dashboard.scope.spec.ts | 9 +- .../server/src/dashboard/dashboard.service.ts | 215 +-- .../src/deposits/deposits.controller.ts | 143 +- .../deposits.purge.controller.spec.ts | 28 + .../src/deposits/deposits.purge.spec.ts | 65 + apps/server/src/deposits/deposits.service.ts | 74 +- .../src/entities/class-schedule.entity.ts | 6 +- .../src/entities/classroom-rental.entity.ts | 8 +- apps/server/src/entities/deposit.entity.ts | 4 +- apps/server/src/entities/index.ts | 3 + apps/server/src/entities/room.entity.ts | 10 +- .../server/src/exams/exams.controller.spec.ts | 20 + apps/server/src/exams/exams.controller.ts | 102 +- apps/server/src/exams/exams.purge.spec.ts | 56 + apps/server/src/exams/exams.service.ts | 30 +- .../expenses/expense-operations.service.ts | 438 +++++ .../src/expenses/expenses.boundaries.spec.ts | 21 +- .../src/expenses/expenses.controller.ts | 208 +-- apps/server/src/expenses/expenses.module.ts | 3 +- .../expenses.purge.controller.spec.ts | 34 + .../src/expenses/expenses.purge.spec.ts | 91 ++ apps/server/src/expenses/expenses.service.ts | 397 +---- .../config/integration-config.service.ts | 3 +- .../src/integration/dingtalk.service.ts | 702 ++------ .../src/integration/jinshuju-student-sync.ts | 2 - .../src/integration/jinshuju.service.ts | 7 +- apps/server/src/integration/wecom.service.ts | 12 +- .../occupancies.controller.spec.ts | 9 + .../src/occupancies/occupancies.controller.ts | 106 +- .../src/occupancies/occupancies.module.ts | 16 +- .../src/occupancies/occupancies.purge.spec.ts | 80 + .../occupancies/occupancies.service.spec.ts | 26 + .../src/occupancies/occupancies.service.ts | 638 +------- .../occupancies/occupancy-import-template.ts | 2 +- .../occupancies/occupancy-import.service.ts | 311 ++++ apps/server/src/occupancies/occupancy-lock.ts | 12 + .../occupancy-operations.service.ts | 420 +++++ .../organizations.controller.spec.ts | 22 + .../organizations/organizations.controller.ts | 19 + .../src/organizations/organizations.module.ts | 8 +- .../organizations/organizations.purge.spec.ts | 78 + .../organizations/organizations.service.ts | 34 +- .../src/rooms/room-bed-locker.service.ts | 190 +++ .../src/rooms/room-inspections.service.ts | 15 +- apps/server/src/rooms/room-number.ts | 38 + apps/server/src/rooms/room-query.service.ts | 282 ++++ apps/server/src/rooms/rooms.controller.ts | 106 +- apps/server/src/rooms/rooms.module.ts | 4 +- .../src/rooms/rooms.purge.controller.spec.ts | 26 + apps/server/src/rooms/rooms.purge.spec.ts | 71 + apps/server/src/rooms/rooms.service.ts | 548 ++----- .../src/schedules/schedule-queries.service.ts | 183 +++ .../src/schedules/schedules.controller.ts | 58 +- apps/server/src/schedules/schedules.module.ts | 3 +- .../src/schedules/schedules.scope.spec.ts | 15 +- .../src/schedules/schedules.service.spec.ts | 6 + .../server/src/schedules/schedules.service.ts | 197 +-- .../src/students/students.agent.service.ts | 233 +++ .../src/students/students.controller.ts | 163 +- .../src/students/students.import.service.ts | 314 ++++ .../students/students.lifecycle.service.ts | 235 +++ apps/server/src/students/students.module.ts | 16 + .../src/students/students.organization.ts | 21 + .../students.purge.controller.spec.ts | 31 + .../src/students/students.purge.spec.ts | 77 + apps/server/src/students/students.service.ts | 670 ++------ apps/server/src/sync/jinshuju-rules.ts | 46 + apps/server/src/sync/schedule-sync.helpers.ts | 185 +++ apps/server/src/sync/schedule-sync.service.ts | 229 +-- apps/server/src/sync/sync-runner.ts | 112 ++ apps/server/src/sync/sync.controller.ts | 4 +- apps/server/src/sync/sync.module.ts | 5 +- apps/server/src/sync/sync.service.spec.ts | 3 + apps/server/src/sync/sync.service.ts | 163 +- apps/server/src/wallets/wallets.service.ts | 66 +- 163 files changed, 18409 insertions(+), 13449 deletions(-) create mode 100644 apps/admin/src/components/JinshujuMatchModal.types.ts create mode 100644 apps/admin/src/components/MatchSelector.tsx create mode 100644 apps/admin/src/components/MatchStep.tsx create mode 100644 apps/admin/src/components/RuleEditor.tsx create mode 100644 apps/admin/src/pages/Classes/ClassDetailTabs.tsx create mode 100644 apps/admin/src/pages/ClassroomRentals/RentalTable.tsx create mode 100644 apps/admin/src/pages/Dashboard/Dashboard.types.ts create mode 100644 apps/admin/src/pages/Dashboard/DashboardCharts.ts create mode 100644 apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx create mode 100644 apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx create mode 100644 apps/admin/src/pages/Deposits/DepositModals.tsx create mode 100644 apps/admin/src/pages/Deposits/DepositTable.tsx create mode 100644 apps/admin/src/pages/Expenses/ExpenseModals.tsx create mode 100644 apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx create mode 100644 apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx create mode 100644 apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx create mode 100644 apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx create mode 100644 apps/admin/src/pages/Occupancies/OccupancyColumns.tsx create mode 100644 apps/admin/src/pages/Occupancies/OccupancyModals.tsx create mode 100644 apps/admin/src/pages/Occupancies/useOccupancyMutations.ts create mode 100644 apps/admin/src/pages/Rooms/RoomColumns.tsx create mode 100644 apps/admin/src/pages/Rooms/RoomDrawer.tsx create mode 100644 apps/admin/src/pages/Rooms/RoomModals.tsx create mode 100644 apps/admin/src/pages/Rooms/RoomsTable.tsx create mode 100644 apps/admin/src/pages/Rooms/RoomsToolbar.tsx create mode 100644 apps/admin/src/pages/Rooms/useRoomMutations.ts create mode 100644 apps/admin/src/pages/Schedules/ScheduleGrids.tsx create mode 100644 apps/admin/src/pages/Schedules/ScheduleModals.tsx create mode 100644 apps/admin/src/pages/Students/StudentColumns.tsx create mode 100644 apps/admin/src/pages/Students/StudentModals.tsx create mode 100644 apps/admin/src/pages/Students/StudentsTable.tsx create mode 100644 apps/admin/src/pages/Students/StudentsToolbar.tsx create mode 100644 apps/server/src/bills/bills-generation.service.ts create mode 100644 apps/server/src/bills/bills.purge.controller.spec.ts create mode 100644 apps/server/src/bills/bills.purge.spec.ts create mode 100644 apps/server/src/classes/classes-queries.service.ts create mode 100644 apps/server/src/classes/classes.purge.spec.ts create mode 100644 apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts create mode 100644 apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts create mode 100644 apps/server/src/classroom-rentals/rental-schedule.service.ts create mode 100644 apps/server/src/classrooms/classrooms.purge.controller.spec.ts create mode 100644 apps/server/src/classrooms/classrooms.purge.spec.ts create mode 100644 apps/server/src/dashboard/dashboard-queries.service.ts create mode 100644 apps/server/src/deposits/deposits.purge.controller.spec.ts create mode 100644 apps/server/src/deposits/deposits.purge.spec.ts create mode 100644 apps/server/src/exams/exams.purge.spec.ts create mode 100644 apps/server/src/expenses/expense-operations.service.ts create mode 100644 apps/server/src/expenses/expenses.purge.controller.spec.ts create mode 100644 apps/server/src/expenses/expenses.purge.spec.ts create mode 100644 apps/server/src/occupancies/occupancies.purge.spec.ts create mode 100644 apps/server/src/occupancies/occupancy-import.service.ts create mode 100644 apps/server/src/occupancies/occupancy-lock.ts create mode 100644 apps/server/src/occupancies/occupancy-operations.service.ts create mode 100644 apps/server/src/organizations/organizations.purge.spec.ts create mode 100644 apps/server/src/rooms/room-bed-locker.service.ts create mode 100644 apps/server/src/rooms/room-number.ts create mode 100644 apps/server/src/rooms/room-query.service.ts create mode 100644 apps/server/src/rooms/rooms.purge.controller.spec.ts create mode 100644 apps/server/src/rooms/rooms.purge.spec.ts create mode 100644 apps/server/src/schedules/schedule-queries.service.ts create mode 100644 apps/server/src/students/students.agent.service.ts create mode 100644 apps/server/src/students/students.import.service.ts create mode 100644 apps/server/src/students/students.lifecycle.service.ts create mode 100644 apps/server/src/students/students.organization.ts create mode 100644 apps/server/src/students/students.purge.controller.spec.ts create mode 100644 apps/server/src/students/students.purge.spec.ts create mode 100644 apps/server/src/sync/jinshuju-rules.ts create mode 100644 apps/server/src/sync/schedule-sync.helpers.ts create mode 100644 apps/server/src/sync/sync-runner.ts diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 7ba297e..4613ef0 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -1,310 +1,28 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Button, Form, Input, Modal, Popconfirm, Select, Spin, Steps, Tag, Typography } from 'antd'; -import { - CloudUploadOutlined, - DeleteOutlined, - EditOutlined, - LinkOutlined, - PlusOutlined, - SaveOutlined, - SearchOutlined, -} from '@ant-design/icons'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useImmer } from 'use-immer'; +import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd'; +import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; import api from '../api'; import { message } from '../ui/app-message'; import { usePermission } from '../hooks/usePermission'; import PermissionButton from './PermissionButton'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../hooks/useApiMutation'; +import { validateResponse } from '../utils/validate'; +import { jinshujuRulesSchema } from '../api/schemas'; +import MatchStep from './MatchStep'; +import RuleEditor from './RuleEditor'; +import type { + JinshujuEntryRow, + JinshujuFormField, + MatchDecision, + MatchRule, + PreviewResponse, + StudentOption, +} from './JinshujuMatchModal.types'; const { Text } = Typography; -// ── Types ── - -interface JinshujuEntryRow { - serialNumber: number; - name: string; - phone: string | null; - suggestedStudent: { - id: number; - name: string; - phone: string | null; - studentNo: string | null; - } | null; -} - -interface StudentOption { - id: number; - name: string; - phone: string | null; - studentNo: string | null; -} - -interface PreviewResponse { - success: boolean; - entries: JinshujuEntryRow[]; - students: StudentOption[]; -} - -interface MatchRule { - id: number; - name: string; - formToken: string; - mappings: Record; - createdAt: string; -} - -interface JinshujuFormField { - key: string; - label: string; - type: string; -} - -type MatchDecision = - | { action: 'match'; matchStudentId: number } - | { action: 'create'; createName: string; createPhone: string } - | { action: 'skip' }; - -// ── Constants ── - -const ROW_HEIGHT = 72; -const LEFT_WIDTH = 260; -const GAP = 80; - -const STUDENT_FIELDS = [ - { key: 'name', label: '姓名' }, - { key: 'phone', label: '手机号' }, - { key: 'idNumber', label: '身份证号' }, - { key: 'gender', label: '性别' }, - { key: 'ethnicity', label: '民族' }, - { key: 'emergencyContact', label: '紧急联系人' }, - { key: 'emergencyPhone', label: '紧急联系电话' }, - { key: 'studentNo', label: '学号' }, -]; - -// ── MatchSelector sub-component ── - -interface MatchSelectorProps { - entry: JinshujuEntryRow; - decision: MatchDecision | undefined; - studentOptions: StudentOption[]; - onChange: (d: MatchDecision) => void; -} - -const MatchSelector: React.FC = ({ - entry, - decision, - studentOptions, - onChange, -}) => { - const action = decision?.action ?? 'skip'; - - if (action === 'match') { - const matchD = decision as { action: 'match'; matchStudentId: number }; - const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId); - return ( -
- }> - 已匹配 - - - {matchedStudent?.name ?? '未知'} - {matchedStudent?.studentNo && ( - - ({matchedStudent.studentNo}) - - )} - - -
- ); - } - - if (action === 'create') { - const createD = decision as { action: 'create'; createName: string; createPhone: string }; - return ( -
- }> - 将新建 - - - onChange({ - action: 'create', - createName: e.target.value, - createPhone: createD.createPhone, - }) - } - /> - - onChange({ - action: 'create', - createName: createD.createName, - createPhone: e.target.value, - }) - } - /> - -
- ); - } - - return ( -
- setName(e.target.value)} - style={{ marginBottom: 12 }} - /> - - 选择金数据字段映射到学生资料 - - {STUDENT_FIELDS.map((sf) => ( -
- {sf.label} - - ← - - + onChange({ + action: 'create', + createName: e.target.value, + createPhone: createD.createPhone, + }) + } + /> + + onChange({ + action: 'create', + createName: createD.createName, + createPhone: e.target.value, + }) + } + /> + +
+ ); + } + + return ( +
+ setName(e.target.value)} + style={{ marginBottom: 12 }} + /> + + 选择金数据字段映射到学生资料 + + {STUDENT_FIELDS.map((sf) => ( +
+ {sf.label} + + ← + + + + + + + + ({ + value: k, + label: v.text, + }))} + /> + + + + + + + + 保存 + + + + + ) : ( +
+ + {TYPE_MAP[detail.classType]} + + {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} + + + {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'} + + + {detail.studentCount}/{detail.maxStudents || '-'} + + + {(() => { + const headTeacher = teachers.find( + (teacher) => teacher.roleType === 'head_teacher', + ); + return headTeacher ? getTeacherName(headTeacher) : '-'; + })()} + + {detail.notes || '-'} + + + 编辑 + +
+ )} +
+ ); +}; + +export const ClassStudentsTab: React.FC<{ + id?: string; + detail?: ClassDetail | null; + students: ClassStudent[]; + allStudents: StudentItem[]; + selectedStudentIds: number[]; + modalOpen: boolean; + onOpen: () => void; + onAdd: () => void; + onClose: () => void; + onRemove: (studentId: number) => void; + onSelect: (ids: number[]) => void; +}> = ({ + id, + detail, + students, + allStudents, + selectedStudentIds, + modalOpen, + onOpen, + onAdd, + onClose, + onRemove, + onSelect, +}) => { + const studentColumns: ColumnsType = [ + { title: '姓名', dataIndex: 'studentName' }, + { title: '学号', dataIndex: 'studentNo' }, + { title: '加入日期', dataIndex: 'joinDate' }, + { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, + { + title: '状态', + dataIndex: 'status', + render: (v: string) => ( + {v === 'active' ? '在读' : '已离班'} + ), + }, + { + title: '操作', + render: (_: unknown, r: ClassStudent) => + r.status === 'active' ? ( + onRemove(r.studentId)}> + + 移除 + + + ) : null, + }, + ]; + return ( +
+ } + type="primary" + onClick={onOpen} + style={{ marginBottom: 16, marginRight: 8 }} + > + 添加学员 + + } + onClick={() => { + const token = useUserStore.getState().token; + fetch(`/api/classes/${id}/roster/export`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + if (!res.ok) throw new Error('导出失败'); + return res.blob(); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `班级花名册-${detail?.name || id}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + message.success('花名册导出成功'); + }) + .catch(() => message.error('花名册导出失败')); + }} + > + 导出花名册 + + + columns={studentColumns} + dataSource={students} + rowKey="id" + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + /> + + + onSubjectChange(e.target.value)} + /> + )} + + +
+ ); +}; + +export const ClassScheduleTab: React.FC<{ + schedules: ClassScheduleItem[]; + scheduleDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null]; + onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void; +}> = ({ schedules, scheduleDateRange, onRangeChange }) => { + const scheduleColumns: ColumnsType = [ + { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, + { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, + { + title: '时间', + render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`, + }, + { + title: '签到窗口', + render: (_: unknown, r: ClassScheduleItem) => + `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`, + }, + { + title: '日期范围', + render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`, + }, + { title: '科目', dataIndex: 'subject' }, + { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, + { + title: '状态', + dataIndex: 'status', + render: (v: string) => ( + {v === 'active' ? '启用' : v} + ), + }, + ]; + return ( +
+ + onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + + columns={scheduleColumns} + dataSource={schedules} + rowKey="id" + scroll={{ x: 'max-content' }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + /> +
+ ); +}; + +export const ClassAttendanceTab: React.FC<{ + attendanceSummary: AttendanceSummary | null; + attendanceDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null]; + onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void; +}> = ({ attendanceSummary, attendanceDateRange, onRangeChange }) => { + return ( +
+ + onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + {attendanceSummary && ( + +
+ + + + + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 19e0fe6..d5cc7f2 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -1,155 +1,30 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useUserStore } from '../../store/user/userStore'; -import { - Card, - Tabs, - Descriptions, - Table, - Button, - Space, - Select, - Modal, - Tag, - Popconfirm, - Form, - Input, - DatePicker, - InputNumber, - Row, - Col, - Statistic, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons'; +import React, { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { Button, Card, Form, Space, Tabs, Tag } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; -import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; - -// ---- Types ---- - -interface ClassStudent { - id: number; - studentId: number; - studentName: string; - studentNo: string; - joinDate: string; - leaveDate: string | null; - status: string; -} - -interface ClassTeacher { - id: number; - userId: number; - username: string; - roleType: string; - subject: string | null; -} - -interface ClassScheduleItem { - id: number; - classId: number; - classroomId: number; - classroomName: string; - weekDay: number; - startTime: string; - endTime: string; - attendanceAdvanceMinutes: number; - startDate: string; - endDate: string; - subject: string; - teacherId: number | null; - scheduleType: string; - status: string; - notes: string | null; -} - -interface AttendanceSummary { - total: number; - present: number; - late: number; - absent: number; - leave: number; - presentRate: number; - absentRate: number; - lateRate: number; - leaveRate: number; -} - -interface ClassDetail { - id: number; - name: string; - code: string; - classType: string; - startDate: string | null; - endDate: string | null; - status: string; - maxStudents: number; - notes: string | null; - studentCount: number; - students?: ClassStudent[]; - teachers?: ClassTeacher[]; - createdAt: string; - updatedAt: string; -} - -interface StudentItem { - id: number; - name: string; - studentNo?: string; -} - -type UserItem = TeacherCandidateUser; - -// ---- Constants ---- - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', - professional: '专业课', - bootcamp: '集训营', - sprint: '冲刺营', -}; - -const ROLE_MAP: Record = { - subject_teacher: '任课老师', - head_teacher: '班主任', - life_teacher: '生活老师', - academic_teacher: '学服老师', -}; - -const WEEK_DAY_MAP: Record = { - 1: '周一', - 2: '周二', - 3: '周三', - 4: '周四', - 5: '周五', - 6: '周六', - 7: '周日', -}; - -const SCHEDULE_TYPE_MAP: Record = { - INTERNAL: '内部排课', - RENTAL: '租赁', -}; - -// ---- Component ---- +import { useQuery } from '@tanstack/react-query'; +import type { TeacherCandidateUser } from './teacher-candidate'; +import { getErrorMessage } from '../../utils/error'; +import { + ClassAttendanceTab, + ClassInfoTab, + ClassScheduleTab, + ClassStudentsTab, + ClassTeachersTab, + STATUS_MAP, + type ClassDetail, + type ClassTeacher, + type StudentItem, + type AttendanceSummary, + type ClassScheduleItem, +} from './ClassDetailTabs'; const ClassDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [detail, setDetail] = useState(null); - const [students, setStudents] = useState([]); - const [teachers, setTeachers] = useState([]); - const [loading, setLoading] = useState(false); const [editForm] = Form.useForm(); const [editingInfo, setEditingInfo] = useState(false); @@ -160,85 +35,93 @@ const ClassDetailPage: React.FC = () => { // Teacher modal state const [teacherModalOpen, setTeacherModalOpen] = useState(false); - const [allUsers, setAllUsers] = useState([]); const [teacherRole, setTeacherRole] = useState('subject_teacher'); const [teacherSubject, setTeacherSubject] = useState(''); const [teacherUserId, setTeacherUserId] = useState(); // Schedule & attendance state - const [schedules, setSchedules] = useState([]); const [scheduleDateRange, setScheduleDateRange] = useState< [dayjs.Dayjs | null, dayjs.Dayjs | null] >([null, null]); - const [attendanceSummary, setAttendanceSummary] = useState(null); const [attendanceDateRange, setAttendanceDateRange] = useState< [dayjs.Dayjs | null, dayjs.Dayjs | null] >([null, null]); - const fetchDetail = useCallback(async () => { - setLoading(true); - try { - const res = (await api.get(`/classes/${id}`)) as ClassDetail; - setDetail(res); - setStudents(res.students || []); - setTeachers(res.teachers || []); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败'); - } finally { - setLoading(false); - } - }, [id]); + const { + data: detailResult = { detail: null, students: [], teachers: [] }, + isLoading: detailLoading, + isFetching: detailFetching, + refetch: refetchDetail, + } = useQuery<{ + detail: ClassDetail | null; + students: ClassDetail['students']; + teachers: ClassDetail['teachers']; + }>({ + queryKey: ['classes', 'detail', id], + queryFn: async () => { + try { + const res = (await api.get(`/classes/${id}`)) as ClassDetail; + return { detail: res, students: res.students || [], teachers: res.teachers || [] }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败')); + return { detail: null, students: [], teachers: [] }; + } + }, + }); + const detail = detailResult.detail; + const students = detailResult.students ?? []; + const teachers = detailResult.teachers ?? []; + const loading = detailLoading || detailFetching; + const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]); - const fetchUsers = useCallback(async () => { - try { - const res = (await api.get('/rbac/users')) as UserItem[]; - setAllUsers(res || []); - } catch { - setAllUsers([]); - } - }, []); + const { data: allUsers = [], refetch: refetchUsers } = useQuery({ + queryKey: ['rbac', 'users', 'all'], + queryFn: async () => { + try { + return (await api.get('/rbac/users')) as TeacherCandidateUser[]; + } catch { + return []; + } + }, + }); + const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]); - useEffect(() => { - fetchDetail(); - fetchUsers(); - }, [fetchDetail, fetchUsers]); + const { data: schedules = [] } = useQuery({ + queryKey: ['classes', 'schedule', id, scheduleDateRange], + queryFn: async () => { + if (!id) return []; + try { + const params: Record = {}; + if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); + if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); + return (await api.get(`/classes/${id}/schedule`, { params })) || []; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载课表失败')); + return []; + } + }, + }); - const fetchSchedules = useCallback(async () => { - if (!id) return; - try { - const params: Record = {}; - if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); - if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); - const res = await api.get(`/classes/${id}/schedule`, { params }); - setSchedules(res || []); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载课表失败'); - } - }, [id, scheduleDateRange]); - - useEffect(() => { - fetchSchedules(); - }, [fetchSchedules]); - - const fetchAttendanceSummary = useCallback(async () => { - if (!id) return; - try { - const params: Record = {}; - if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); - if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); - const res = await api.get(`/classes/${id}/attendance-summary`, { params }); - setAttendanceSummary(res || null); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载出勤汇总失败'); - } - }, [id, attendanceDateRange]); - - useEffect(() => { - fetchAttendanceSummary(); - }, [fetchAttendanceSummary]); + const { data: attendanceSummary = null } = useQuery({ + queryKey: ['classes', 'attendance-summary', id, attendanceDateRange], + queryFn: async () => { + if (!id) return null; + try { + const params: Record = {}; + if (attendanceDateRange?.[0]) + params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); + if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); + return ( + (await api.get(`/classes/${id}/attendance-summary`, { + params, + })) || null + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载出勤汇总失败')); + return null; + } + }, + }); const handleSaveInfo = async () => { try { @@ -257,8 +140,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已更新'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '更新失败'); + message.error(getErrorMessage(e, '更新失败')); } }; @@ -268,8 +150,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已移除'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '移除失败'); + message.error(getErrorMessage(e, '移除失败')); } }; @@ -282,8 +163,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已添加'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '添加失败'); + message.error(getErrorMessage(e, '添加失败')); } }; @@ -299,8 +179,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已添加'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '添加失败'); + message.error(getErrorMessage(e, '添加失败')); } }; @@ -310,8 +189,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已移除'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '移除失败'); + message.error(getErrorMessage(e, '移除失败')); } }; @@ -324,8 +202,7 @@ const ClassDetailPage: React.FC = () => { setSelectedStudentIds([]); setStudentModalOpen(true); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载学员列表失败'); + message.error(getErrorMessage(e, '加载学员列表失败')); } }; @@ -337,8 +214,7 @@ const ClassDetailPage: React.FC = () => { setTeacherSubject(''); setTeacherModalOpen(true); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载用户列表失败'); + message.error(getErrorMessage(e, '加载用户列表失败')); } }; @@ -347,82 +223,6 @@ const ClassDetailPage: React.FC = () => { if (!detail) return null; - const studentColumns: ColumnsType = [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo' }, - { title: '加入日期', dataIndex: 'joinDate' }, - { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, - { - title: '状态', - dataIndex: 'status', - render: (v: string) => ( - {v === 'active' ? '在读' : '已离班'} - ), - }, - { - title: '操作', - render: (_: unknown, r: ClassStudent) => - r.status === 'active' ? ( - handleRemoveStudent(r.studentId)}> - - 移除 - - - ) : null, - }, - ]; - - const teacherColumns: ColumnsType = [ - { title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) }, - { - title: '角色', - dataIndex: 'roleType', - render: (v: string) => {ROLE_MAP[v] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string | null) => v || '-', - }, - { - title: '操作', - render: (_: unknown, r: ClassTeacher) => ( - handleRemoveTeacher(r.userId)}> - - 移除 - - - ), - }, - ]; - - const scheduleColumns: ColumnsType = [ - { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, - { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, - { - title: '时间', - render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`, - }, - { - title: '签到窗口', - render: (_: unknown, r: ClassScheduleItem) => - `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`, - }, - { - title: '日期范围', - render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`, - }, - { title: '科目', dataIndex: 'subject' }, - { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, - { - title: '状态', - dataIndex: 'status', - render: (v: string) => ( - {v === 'active' ? '启用' : v} - ), - }, - ]; - return ( { key: 'info', label: '基本信息', children: ( -
- {editingInfo ? ( -
- - - - - - - - - ({ - value: k, - label: v.text, - }))} - /> - - - - - - - - 保存 - - - - - ) : ( -
- - - {TYPE_MAP[detail.classType]} - - - {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} - - - {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'} - - - {detail.studentCount}/{detail.maxStudents || '-'} - - - {(() => { - const headTeacher = teachers.find( - (teacher) => teacher.roleType === 'head_teacher', - ); - return headTeacher ? getTeacherName(headTeacher) : '-'; - })()} - - {detail.notes || '-'} - - { - editForm.setFieldsValue({ - name: detail.name, - code: detail.code, - classType: detail.classType, - startDate: detail.startDate ? dayjs(detail.startDate) : undefined, - endDate: detail.endDate ? dayjs(detail.endDate) : undefined, - maxStudents: detail.maxStudents, - status: detail.status, - notes: detail.notes, - }); - setEditingInfo(true); - }} - > - 编辑 - -
- )} -
+ { + editForm.setFieldsValue({ + name: detail.name, + code: detail.code, + classType: detail.classType, + startDate: detail.startDate ? dayjs(detail.startDate) : undefined, + endDate: detail.endDate ? dayjs(detail.endDate) : undefined, + maxStudents: detail.maxStudents, + status: detail.status, + notes: detail.notes, + }); + setEditingInfo(true); + }} + onCancel={() => setEditingInfo(false)} + getTeacherName={getTeacherName} + /> ), }, { key: 'students', label: `花名册 (${students.filter((s) => s.status === 'active').length})`, children: ( -
- } - type="primary" - onClick={openStudentModal} - style={{ marginBottom: 16, marginRight: 8 }} - > - 添加学员 - - } - onClick={() => { - const token = useUserStore.getState().token; - fetch(`/api/classes/${id}/roster/export`, { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((res) => { - if (!res.ok) throw new Error('导出失败'); - return res.blob(); - }) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `班级花名册-${detail?.name || id}.xlsx`; - a.click(); - URL.revokeObjectURL(url); - message.success('花名册导出成功'); - }) - .catch(() => message.error('花名册导出失败')); - }} - > - 导出花名册 - - - columns={studentColumns} - dataSource={students} - rowKey="id" - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - }} - /> - setStudentModalOpen(false)} - > - - setTeacherSubject(e.target.value)} - /> - )} - - -
+ setTeacherModalOpen(false)} + onRemove={handleRemoveTeacher} + onRoleChange={setTeacherRole} + onSubjectChange={setTeacherSubject} + onUserChange={setTeacherUserId} + getTeacherName={getTeacherName} + /> ), }, { key: 'schedule', label: '课表', children: ( -
- - - setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) - } - placeholder={['开始日期', '结束日期']} - /> - - - columns={scheduleColumns} - dataSource={schedules} - rowKey="id" - scroll={{ x: 'max-content' }} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - }} - /> -
+ ), }, { key: 'attendance-summary', label: '出勤汇总', children: ( -
- - - setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) - } - placeholder={['开始日期', '结束日期']} - /> - - {attendanceSummary && ( - -
- - - - - - - - - - - - - - - - - - - - - )} - + ), }, ]} diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 80c65be..e12af51 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -1,5 +1,11 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { classesSchema } from '../../api/schemas'; import { + App, Table, Button, Input, @@ -17,14 +23,13 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; - -// ---- Types ---- +import { usePermission } from '../../hooks/usePermission'; interface ClassItem { id: number; @@ -56,8 +61,6 @@ interface ClassFormValues { notes?: string; } -// ---- Constants ---- - const STATUS_MAP: Record = { enrolling: { color: 'blue', text: '招生中' }, active: { color: 'green', text: '在读' }, @@ -72,12 +75,11 @@ const TYPE_MAP: Record = { sprint: '冲刺营', }; -// ---- Component ---- - const ClassesPage: React.FC = () => { + const { modal } = App.useApp(); const navigate = useNavigate(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); + const { hasPermission } = usePermission(); + const canPurgeClass = hasPermission('class:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [searchText, setSearchText] = useState(''); @@ -89,34 +91,74 @@ const ClassesPage: React.FC = () => { const handleArchive = async (id: number, archive: boolean) => { try { - await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`); + await archiveMutation.mutateAsync({ id, archive }); message.success(archive ? '已归档' : '已恢复'); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params: Record = {}; - if (filterStatus) params.status = filterStatus; - if (filterType) params.classType = filterType; - params.isArchived = showArchived; - const res = await api.get('/classes', { params } as Record); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } finally { - setLoading(false); - } - }, [filterStatus, filterType, showArchived]); + const handlePurge = (record: ClassItem) => { + modal.confirm({ + title: `永久删除班级「${record.name}」?`, + content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; - useEffect(() => { - fetchData(); - }, [fetchData]); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classes', filterStatus, filterType, showArchived], + queryFn: async () => { + try { + const params: Record = {}; + if (filterStatus) params.status = filterStatus; + if (filterType) params.classType = filterType; + params.isArchived = showArchived; + return validateResponse( + classesSchema, + await api.get('/classes', { params } as Record), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (payload: Record) => + editing ? api.put(`/classes/${editing.id}`, payload) : api.post('/classes', payload), + { invalidate: [['classes']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: ClassItem; field: string; value: unknown }) => + api.put(`/classes/${record.id}`, { [field]: value }), + { invalidate: [['classes']] }, + ); + const archiveMutation = useApiMutation( + async ({ id, archive }: { id: number; archive: boolean }) => + api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`), + { invalidate: [['classes']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classes/${id}/permanent`), + { invalidate: [['classes']] }, + ); const filtered = useMemo(() => { if (!searchText) return data; @@ -152,17 +194,11 @@ const ClassesPage: React.FC = () => { startDate: values.startDate?.format('YYYY-MM-DD'), endDate: values.endDate?.format('YYYY-MM-DD'), }; - if (editing) { - await api.put(`/classes/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classes', payload); - message.success('创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -170,11 +206,14 @@ const ClassesPage: React.FC = () => { const saveCell = useCallback( async (record: ClassItem, field: string, value: unknown) => { - await api.put(`/classes/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns: ColumnsType = useMemo( @@ -196,6 +235,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '编码', dataIndex: 'code', @@ -212,6 +252,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '班型', dataIndex: 'classType', @@ -229,6 +270,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '开班日期', dataIndex: 'startDate', @@ -245,6 +287,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '学员', width: 100, @@ -259,6 +302,7 @@ const ClassesPage: React.FC = () => { >{`${r.studentCount || 0}/${r.maxStudents || '-'}`} ), }, + { title: '状态', dataIndex: 'status', @@ -282,6 +326,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '操作', width: 280, @@ -298,11 +343,18 @@ const ClassesPage: React.FC = () => { 编辑 {r.isArchived ? ( - handleArchive(r.id, false)}> - - 恢复 - - + <> + handleArchive(r.id, false)}> + + 恢复 + + + {canPurgeClass ? ( + + ) : null} + ) : ( { ), }, ], - [saveCell], + [saveCell, canPurgeClass, handlePurge], ); return ( diff --git a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx new file mode 100644 index 0000000..9a58f71 --- /dev/null +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -0,0 +1,340 @@ +import React from 'react'; +import { + Button, + Empty, + Popconfirm, + Space, + Table, + Tag, + Tooltip, + Upload, +} from 'antd'; +import { + CheckOutlined, + FileTextOutlined, + StopOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import { message } from '../../ui/app-message'; + +const RENTAL_FIELDS = { + classroomId: 'classroomId', + lesseeOrganizationId: 'lesseeOrganizationId', + startDate: 'startDate', + endDate: 'endDate', + dailyRate: 'dailyRate', + totalAmount: 'totalAmount', +} as const; + +export interface RentalTableProps { + data: any[]; + loading: boolean; + classrooms: any[]; + organizations: any[]; + canPurgeRental: boolean; + hasPermission: (permission: string) => boolean; + onSaveCell: (record: any, field: string, value: unknown) => Promise | void; + onEdit: (record: any) => void; + onAction: (id: number, action: 'cancel' | 'end') => void; + onArchive: (id: number) => void; + onPurge: (id: number, name: string) => void; + onDownloadContract: (id: number, filename?: string) => void; + onDeleteContract: (id: number) => void; + onUploadContract: (id: number, formData: FormData) => Promise; +} + +export const RentalTable: React.FC = ({ + data, + loading, + classrooms, + organizations, + canPurgeRental, + hasPermission, + onSaveCell, + onEdit, + onAction, + onArchive, + onPurge, + onDownloadContract, + onDeleteContract, + onUploadContract, +}) => { + const EditableRentalCell = ({ + value, + field, + record, + editor, + min, + required, + options, + children, + }: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + children?: React.ReactNode; + }) => ( + { + await onSaveCell(record, field, next); + }} + > + {children ?? String(value ?? '-')} + + ); + + const columns = [ + { + title: '教室', + width: 120, + dataIndex: 'classroom', + render: (c: any, r: any) => ( + item.status !== 'archived') + .map((item) => ({ + value: item.id, + label: item.building ? `${item.building} · ${item.name}` : item.name, + }))} + required + > + {c ? ( + + {c.building ? `${c.building} · ` : ''} + {c.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '承租机构', + width: 100, + dataIndex: 'lesseeOrganization', + render: (t: any, r: any) => ( + item.status !== 'archived') + .map((item) => ({ value: item.id, label: item.name }))} + required + > + {t ? ( + + {t.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '开始日期', + dataIndex: 'startDate', + width: 110, + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '结束日期', + dataIndex: 'endDate', + width: 110, + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '时长', + width: 80, + render: (_: any, r: any) => { + const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1; + return `${d}天`; + }, + }, + { + title: '日租金', + dataIndex: 'dailyRate', + width: 100, + render: (v: any, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + { + title: '总额', + dataIndex: 'totalAmount', + width: 100, + render: (v: any, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + { + title: '状态', + dataIndex: 'effectiveStatus', + width: 90, + render: (status: string) => { + const config: Record = { + active: { text: '进行中', color: 'green' }, + ended: { text: '已结束', color: 'default' }, + cancelled: { text: '已取消', color: 'red' }, + }; + return {config[status]?.text || status}; + }, + }, + { + title: '合同', + width: 120, + dataIndex: 'contractPath', + render: (v: string, r: any) => + v ? ( + + + + + {hasPermission('rental:edit') ? ( + onDeleteContract(r.id)}> + + + ) : ( + '-' + ), + }, + { + title: '操作', + width: 150, + render: (_: any, record: any) => ( + + {record.effectiveStatus === 'active' && ( + <> + onEdit(record)}> + 编辑 + + onAction(record.id, 'cancel')}> + } + > + 取消 + + + {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( + onAction(record.id, 'end')}> + }> + 结束 + + + )} + + )} + {record.effectiveStatus !== 'active' && ( + onArchive(record.id)} + > + + 归档 + + + )} + {record.status === 'cancelled' && canPurgeRental ? ( + + ) : null} + + ), + }, + ]; + + return ( +
}} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1200 }} + /> + ); +}; diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 3e15d40..d89d498 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useImmer } from 'use-immer'; import { - Table, - Button, + App, Modal, Form, Select, @@ -9,39 +9,32 @@ import { InputNumber, Input, Space, - Tag, - Popconfirm, - Upload, - Tooltip, - Empty, } from 'antd'; -import { - PlusOutlined, - UploadOutlined, - FileTextOutlined, - StopOutlined, - CheckOutlined, -} from '@ant-design/icons'; +import { PlusOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { getErrorMessage } from '../../utils/error'; +import { validateResponse } from '../../utils/validate'; +import { classroomsSchema, organizationsSchema, rentalsSchema } from '../../api/schemas'; +import { RentalTable } from './RentalTable'; interface UnavailableDatesResponse { dates: string[]; } + export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) => `${classroomId}:${date.format('YYYY-MM')}`; const ClassroomRentalsPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission, hasAnyPermission } = usePermission(); - const [data, setData] = useState([]); - const [classrooms, setClassrooms] = useState([]); - const [organizations, setOrganizations] = useState([]); - const [loading, setLoading] = useState(false); + const canPurgeRental = hasPermission('rental:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -49,12 +42,110 @@ const ClassroomRentalsPage: React.FC = () => { const [filterStatus, setFilterStatus] = useState(); const [searchText, setSearchText] = useState(''); const [saving, setSaving] = useState(false); - const [unavailableDates, setUnavailableDates] = useState>(new Set()); + const [unavailableDates, setUnavailableDates] = useImmer>(new Set()); const loadedUnavailableMonths = useRef>(new Set()); const unavailableRequestVersion = useRef(0); const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false); const selectedClassroomId = Form.useWatch('classroomId', form); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classroom-rentals', filterMonth], + queryFn: async () => { + try { + const params: any = {}; + if (filterMonth) params.month = filterMonth.format('YYYY-MM'); + params.includeEnded = true; + return validateResponse( + rentalsSchema, + await api.get('/classroom-rentals', { params }), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const { + data: meta = { classrooms: [], organizations: [] }, + } = useQuery<{ classrooms: any[]; organizations: any[] }>({ + queryKey: ['classroom-rentals', 'meta'], + enabled: hasAnyPermission('rental:create', 'rental:edit'), + queryFn: async () => { + try { + const [cr, tn]: any = await Promise.all([ + api.get('/classrooms'), + api.get('/organizations', { params: { scope: 'all' } }), + ]); + return { + classrooms: validateResponse(classroomsSchema, cr), + organizations: validateResponse(organizationsSchema, tn), + }; + } catch (e: any) { + message.error(e?.message || '加载教室列表失败'); + return { classrooms: [], organizations: [] }; + } + }, + }); + const classrooms = meta.classrooms; + const organizations = meta.organizations; + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (payload: Record) => + editing + ? api.put(`/classroom-rentals/${editing.id}`, payload) + : api.post('/classroom-rentals', payload), + { + invalidate: [['classroom-rentals']], + onError: (error: unknown) => { + const e = error as { + conflicts?: Array<{ organizationName?: string; startDate?: string; endDate?: string }>; + }; + if (e?.conflicts?.length) { + const list = e.conflicts + .map((c) => `${c.organizationName}(${c.startDate}~${c.endDate})`) + .join('、'); + message.error(`时间段冲突:${list}`); + } else { + message.error(getErrorMessage(error, '操作失败')); + } + }, + }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/classroom-rentals/${record.id}`, { [field]: value }), + { invalidate: [['classroom-rentals']] }, + ); + const deleteMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}`), + { invalidate: [['classroom-rentals']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}/permanent`), + { invalidate: [['classroom-rentals']] }, + ); + const actionMutation = useApiMutation( + async ({ id, action }: { id: number; action: 'cancel' | 'end' }) => + api.put(`/classroom-rentals/${id}/${action}`), + { invalidate: [['classroom-rentals']] }, + ); + const deleteContractMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}/contract`), + { invalidate: [['classroom-rentals']] }, + ); + const uploadContractMutation = useApiMutation( + async ({ id, formData }: { id: number; formData: FormData }) => + api.post(`/classroom-rentals/${id}/contract`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['classroom-rentals']] }, + ); + const filteredData = useMemo(() => { return data.filter((r: any) => { if (filterStatus && r.effectiveStatus !== filterStatus) return false; @@ -66,40 +157,6 @@ const ClassroomRentalsPage: React.FC = () => { }); }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - const params: any = {}; - if (filterMonth) params.month = filterMonth.format('YYYY-MM'); - params.includeEnded = true; - const res: any = await api.get('/classroom-rentals', { params }); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }; - - const fetchMeta = async () => { - try { - const [cr, tn]: any = await Promise.all([ - api.get('/classrooms'), - api.get('/organizations', { params: { scope: 'all' } }), - ]); - setClassrooms(cr); - setOrganizations(tn); - } catch (e: any) { - message.error(e?.message || '加载教室列表失败'); - } - }; - - useEffect(() => { - if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta(); - }, [hasAnyPermission]); - useEffect(() => { - fetchData(); - }, [filterMonth]); - const resetUnavailableDates = () => { unavailableRequestVersion.current += 1; loadedUnavailableMonths.current.clear(); @@ -127,10 +184,8 @@ const ClassroomRentalsPage: React.FC = () => { }, ); if (requestVersion !== unavailableRequestVersion.current) return; - setUnavailableDates((current) => { - const next = new Set(current); - response.dates.forEach((item) => next.add(item)); - return next; + setUnavailableDates((draft) => { + response.dates.forEach((item) => draft.add(item)); }); } catch (e: any) { loadedUnavailableMonths.current.delete(key); @@ -190,75 +245,85 @@ const ClassroomRentalsPage: React.FC = () => { notes: values.notes, }; try { - if (editing) { - await api.put(`/classroom-rentals/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classroom-rentals', payload); - message.success('创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); setEditing(null); - fetchData(); - } catch (e: any) { - if (e?.conflicts?.length) { - const list = e.conflicts - .map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`) - .join('、'); - message.error(`时间段冲突:${list}`); - } else { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: any, field: string, value: unknown) => { - await api.put(`/classroom-rentals/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const handleDelete = async (id: number) => { try { - await api.delete(`/classroom-rentals/${id}`); + await deleteMutation.mutateAsync(id); message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除租赁订单(${name})?`, + content: '删除后不可恢复,排课与合同文件将被清除(存在考勤记录时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleRentalAction = async (id: number, action: 'cancel' | 'end') => { try { - await api.put(`/classroom-rentals/${id}/${action}`); + await actionMutation.mutateAsync({ id, action }); message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleDownloadContract = async (id: number, filename?: string) => { try { await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`); - } catch { + } catch (e) { + console.error('下载合同失败', e); message.error('下载失败(可能文件已丢失)'); } }; const handleDeleteContract = async (id: number) => { try { - await api.delete(`/classroom-rentals/${id}/contract`); + await deleteContractMutation.mutateAsync(id); message.success('合同已移除'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '移除失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handleUploadContract = async (id: number, formData: FormData) => { + return uploadContractMutation.mutateAsync({ id, formData }); + }; + const openEdit = (record: any) => { setEditing(record); resetUnavailableDates(); @@ -280,271 +345,6 @@ const ClassroomRentalsPage: React.FC = () => { ); }; - const columns = useMemo( - () => [ - { - title: '教室', - width: 120, - dataIndex: 'classroom', - render: (c: any, r: any) => ( - item.status !== 'archived') - .map((item) => ({ - value: item.id, - label: item.building ? `${item.building} · ${item.name}` : item.name, - }))} - permission="rental:edit" - disabled={r.effectiveStatus !== 'active'} - required - onSave={(next) => saveCell(r, 'classroomId', next)} - > - {c ? ( - - {c.building ? `${c.building} · ` : ''} - {c.name} - - ) : ( - '-' - )} - - ), - }, - { - title: '承租机构', - width: 100, - dataIndex: 'lesseeOrganization', - render: (t: any, r: any) => ( - item.status !== 'archived') - .map((item) => ({ value: item.id, label: item.name }))} - permission="rental:edit" - disabled={r.effectiveStatus !== 'active'} - required - onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)} - > - {t ? ( - - {t.name} - - ) : ( - '-' - )} - - ), - }, - { - title: '开始日期', - dataIndex: 'startDate', - width: 110, - render: (v: string, r: any) => ( - saveCell(r, 'startDate', next)} - > - {v} - - ), - }, - { - title: '结束日期', - dataIndex: 'endDate', - width: 110, - render: (v: string, r: any) => ( - saveCell(r, 'endDate', next)} - > - {v} - - ), - }, - { - title: '时长', - width: 80, - render: (_: any, r: any) => { - const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1; - return `${d}天`; - }, - }, - { - title: '日租金', - dataIndex: 'dailyRate', - width: 100, - render: (v: any, r: any) => ( - saveCell(r, 'dailyRate', next)} - > - {v ? `¥${v}` : '-'} - - ), - }, - { - title: '总额', - dataIndex: 'totalAmount', - width: 100, - render: (v: any, r: any) => ( - saveCell(r, 'totalAmount', next)} - > - {v ? `¥${v}` : '-'} - - ), - }, - { - title: '状态', - dataIndex: 'effectiveStatus', - width: 90, - render: (status: string) => { - const config: Record = { - active: { text: '进行中', color: 'green' }, - ended: { text: '已结束', color: 'default' }, - cancelled: { text: '已取消', color: 'red' }, - }; - return {config[status]?.text || status}; - }, - }, - { - title: '合同', - width: 120, - dataIndex: 'contractPath', - render: (v: string, r: any) => - v ? ( - - - - - {hasPermission('rental:edit') ? ( - handleDeleteContract(r.id)}> - - - ) : ( - '-' - ), - }, - { - title: '操作', - width: 150, - render: (_: any, record: any) => ( - - {record.effectiveStatus === 'active' && ( - <> - openEdit(record)} - > - 编辑 - - handleRentalAction(record.id, 'cancel')} - > - } - > - 取消 - - - {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( - handleRentalAction(record.id, 'end')} - > - } - > - 结束 - - - )} - - )} - {record.effectiveStatus !== 'active' && ( - handleDelete(record.id)} - > - - 归档 - - - )} - - ), - }, - ], - [classrooms, organizations, hasPermission], - ); - return (
{ 新增租赁
-
}} - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - scroll={{ x: 1200 }} + classrooms={classrooms} + organizations={organizations} + canPurgeRental={canPurgeRental} + hasPermission={hasPermission} + onSaveCell={saveCell} + onEdit={openEdit} + onAction={handleRentalAction} + onArchive={handleDelete} + onPurge={handlePurge} + onDownloadContract={handleDownloadContract} + onDeleteContract={handleDeleteContract} + onUploadContract={handleUploadContract} /> { const [month, setMonth] = useState(dayjs()); - const [loading, setLoading] = useState(false); - const [data, setData] = useState(null); const [detailModal, setDetailModal] = useState(null); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const res: any = await api.get('/classroom-rentals/schedule', { - params: { year: month.year(), month: month.month() + 1 }, - }); - setData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [month]); - - useEffect(() => { - fetchData(); - }, [fetchData]); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()], + queryFn: async () => { + try { + return validateResponse( + classroomScheduleSchema, + await api.get('/classroom-rentals/schedule', { + params: { year: month.year(), month: month.month() + 1 }, + }), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return null; + } + }, + }); + const loading = isLoading || isFetching; // 按楼栋+楼层分组教室 const groups = useMemo(() => { @@ -88,8 +90,7 @@ const ClassroomSchedulePage: React.FC = () => { const res: any = await api.get(`/classroom-rentals/${rentalId}`); setDetailModal(res); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载详情失败'); + message.error(getErrorMessage(e, '加载详情失败')); } }; diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index dfe611a..ecc4e4f 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -1,5 +1,10 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { classroomsSchema } from '../../api/schemas'; import { + App, Table, Button, Modal, @@ -50,9 +55,8 @@ const typeColor: Record = { }; const ClassroomsPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission } = usePermission(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); @@ -62,6 +66,56 @@ const ClassroomsPage: React.FC = () => { const [saving, setSaving] = useState(false); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classrooms', showArchived], + queryFn: async () => { + try { + return validateResponse( + classroomsSchema, + await api.get('/classrooms', { params: { includeArchived: showArchived } }), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (values: Record) => + editing ? api.put(`/classrooms/${editing.id}`, values) : api.post('/classrooms', values), + { invalidate: [['classrooms']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/classrooms/${record.id}`, { [field]: value }), + { invalidate: [['classrooms']] }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/classrooms/${id}`), + { invalidate: [['classrooms']] }, + ); + const restoreMutation = useApiMutation( + async (id: number) => api.put(`/classrooms/${id}/restore`), + { invalidate: [['classrooms']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classrooms/${id}/permanent`), + { invalidate: [['classrooms']] }, + ); + const importMutation = useApiMutation( + async (formData: FormData) => + api.post('/classrooms/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['classrooms']] }, + ); + const filteredData = useMemo(() => { let result = data; if (searchText) { @@ -77,69 +131,67 @@ const ClassroomsPage: React.FC = () => { return result; }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } }); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }; - - useEffect(() => { - fetchData(); - }, [showArchived]); - const handleSave = async () => { const values = await form.validateFields(); setSaving(true); try { - if (editing) { - await api.put(`/classrooms/${editing.id}`, values); - message.success('更新成功'); - } else { - await api.post('/classrooms', values); - message.success('创建成功'); - } + await saveMutation.mutateAsync(values); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); setEditing(null); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: any, field: string, value: unknown) => { - await api.put(`/classrooms/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const handleArchive = async (id: number) => { try { - await api.delete(`/classrooms/${id}`); + await archiveMutation.mutateAsync(id); message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleRestore = async (id: number) => { try { - await api.put(`/classrooms/${id}/restore`); + await restoreMutation.mutateAsync(id); message.success('已恢复'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除教室「${name}」?`, + content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD ? '/api' @@ -177,6 +229,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '楼栋', dataIndex: 'building', @@ -192,6 +245,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '楼层', dataIndex: 'floor', @@ -208,6 +262,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '类型', width: 90, @@ -225,6 +280,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '容量', dataIndex: 'capacity', @@ -242,6 +298,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '状态', width: 100, @@ -278,22 +335,35 @@ const ClassroomsPage: React.FC = () => { ); }, }, + { title: '操作', width: 180, render: (_: any, record: any) => ( {record.status === 'archived' ? ( - handleRestore(record.id)}> - } - type="link" - > - 恢复 - - + <> + handleRestore(record.id)}> + } + type="link" + > + 恢复 + + + {hasPermission('classroom:purge') ? ( + + ) : null} + ) : ( <> { ), }, ], - [], + [handlePurge, hasPermission], ); return ( @@ -413,15 +483,11 @@ const ClassroomsPage: React.FC = () => { const formData = new FormData(); formData.append('file', file); try { - const res: any = await api.post('/classrooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + const res: any = await importMutation.mutateAsync(formData); message.success(res.message); onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); + } catch (e) { + onError?.(e as Error); } }} > diff --git a/apps/admin/src/pages/Dashboard/Dashboard.types.ts b/apps/admin/src/pages/Dashboard/Dashboard.types.ts new file mode 100644 index 0000000..c5d1a2c --- /dev/null +++ b/apps/admin/src/pages/Dashboard/Dashboard.types.ts @@ -0,0 +1,128 @@ +import React from 'react'; + +export const COLORS = [ + '#007AFF', + '#34C759', + '#FF9500', + '#FF3B30', + '#5AC8FA', + '#AF52DE', + '#FF2D55', + '#FFCC00', +]; + +export interface BillStatRow { + status: string; + count: string; + total: string; +} +export interface ClassAttendanceRank { + className: string; + present: number; + total: number; + rate: number; +} +export interface ClassroomOccupancy { + name: string; + building: string; + capacity: number; + scheduleDays: number; + rentalCount: number; + occupancy: number; +} +export interface ClassroomUtilStats { + totalClassrooms: number; + inUseCount: number; + utilizationRate: string; + scheduleCount: number; + rentalCount: number; +} +export interface AttendanceTrendRow { + date: string; + rate: string; +} +export interface IncomeTrendRow { + month: string; + amount: number; +} +export interface OccupancyByBuildingRow { + building: string; + count: string; +} +export interface ExpenseByTypeRow { + type: string; + total: string; +} +export interface GanttOccupancy { + studentName: string; + studentId?: string; + checkInDate: string; + checkOutDate: string | null; + billingStartDate?: string; + billingEndDate?: string; +} +export interface GanttRoom { + roomNumber: string; + occupancies: GanttOccupancy[]; +} + +export interface DashboardStats { + totalRooms: number; + totalStudents: number; + occupiedBeds: number; + totalCapacity: number; + occupancyRate: string; + billStats: BillStatRow[]; + classroomCount: number; + classroomOccupancyRate: string; + todayAttendanceRate?: string; + monthlyIncome: number; + classCount: number; + teacherCount: number; + pendingDeposits: number; + activeRentals: number; + todayPresent: number; + occupancyByBuilding: OccupancyByBuildingRow[]; + attendanceByStatus: Record; + expenseByType: ExpenseByTypeRow[]; + attendanceTrend: AttendanceTrendRow[]; + incomeTrend: IncomeTrendRow[]; +} + +export const attendanceLabelMap: Record = { + present: '出勤', + absent: '缺勤', + late: '迟到', + early: '早退', + leave: '请假', +}; + +export const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 }; +export const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 }; + +export const TODO_CARD_BASE: React.CSSProperties = { + cursor: 'pointer', + transition: 'box-shadow 0.2s, transform 0.2s', + borderRadius: 8, + height: '100%', +}; +export const TODO_CARD_WARN: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #FF9500', + background: '#fff7e6', +}; +export const TODO_CARD_DANGER: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #FF3B30', + background: '#fff1f0', +}; +export const TODO_CARD_OK: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #34C759', + background: '#f0fff4', +}; +export const TODO_CARD_DRAFT: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #AF52DE', + background: '#f9f0ff', +}; diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.ts new file mode 100644 index 0000000..641e53d --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.ts @@ -0,0 +1,262 @@ +import type { EChartsOption } from '../../components/ECharts'; +import { + attendanceLabelMap, + COLORS, + type AttendanceTrendRow, + type ClassAttendanceRank, + type ClassroomOccupancy, + type DashboardStats, + type ExpenseByTypeRow, + type GanttRoom, + type IncomeTrendRow, +} from './Dashboard.types'; + +export function buildAttendanceRingOption(stats: DashboardStats | null): EChartsOption { + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0 }, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '45%'], + data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ + name: attendanceLabelMap[status] ?? status, + value: count, + })), + itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, + }, + ], + color: COLORS, + }; +} + +export function buildRoomRankingBarOption( + roomRanking: Array<{ roomNumber: string; total: string }>, +): EChartsOption { + return { + tooltip: {}, + grid: { left: 80, right: 20, bottom: 30, top: 10 }, + xAxis: { type: 'value' }, + yAxis: { + type: 'category', + data: roomRanking.map((r) => r.roomNumber).reverse(), + inverse: false, + }, + series: [ + { + type: 'bar', + data: roomRanking.map((r) => Number(r.total)).reverse(), + itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] }, + }, + ], + }; +} + +export function buildClassRankingOption( + rows: ClassAttendanceRank[], + color: string, +): EChartsOption { + return { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (v: number) => `${v}%`, + }, + grid: { left: 80, right: 30, bottom: 30, top: 10 }, + xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, + yAxis: { + type: 'category', + data: rows.map((r) => r.className), + inverse: true, + }, + series: [ + { + type: 'bar', + data: rows.map((r) => r.rate), + itemStyle: { color, borderRadius: [0, 4, 4, 0] }, + label: { show: true, position: 'right', formatter: '{c}%' }, + }, + ], + }; +} + +export function buildAttendanceLineOption(rows: AttendanceTrendRow[]): EChartsOption { + return { + tooltip: { trigger: 'axis' }, + grid: { left: 50, right: 20, bottom: 30, top: 10 }, + xAxis: { + type: 'category', + data: rows.map((d) => d.date), + axisLabel: { rotate: 45, fontSize: 10 }, + }, + yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, + series: [ + { + type: 'line', + data: rows.map((d) => parseFloat(d.rate) || 0), + smooth: true, + lineStyle: { color: '#007AFF', width: 2 }, + itemStyle: { color: '#007AFF' }, + areaStyle: { color: 'rgba(0,122,255,0.1)' }, + }, + ], + }; +} + +export function buildIncomeLineOption(rows: IncomeTrendRow[]): EChartsOption { + return { + tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, + grid: { left: 70, right: 20, bottom: 30, top: 10 }, + xAxis: { + type: 'category', + data: rows.map((d) => d.month), + }, + yAxis: { + type: 'value', + axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` }, + }, + series: [ + { + type: 'line', + data: rows.map((d) => d.amount), + smooth: true, + lineStyle: { color: '#34C759', width: 2 }, + itemStyle: { color: '#34C759' }, + areaStyle: { color: 'rgba(52,199,89,0.1)' }, + }, + ], + }; +} + +export function buildExpensePieOption( + rows: ExpenseByTypeRow[], + expenseTypeMap: Record, +): EChartsOption { + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0 }, + color: COLORS, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '45%'], + data: rows.map((e) => ({ + name: expenseTypeMap[e.type] ?? e.type, + value: Number(e.total), + })), + }, + ], + }; +} + +export function buildClassroomHeatmapOption( + classroomOccupancy: ClassroomOccupancy[], +): EChartsOption { + return { + tooltip: { + formatter: (p: { + name: string; + data: { scheduleDays: number; rentalCount: number; occupancy: number }; + }) => + `${p.name}
排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, + }, + grid: { left: 100, right: 20, bottom: 30, top: 10 }, + xAxis: { type: 'value', max: 1 }, + yAxis: { + type: 'category', + data: classroomOccupancy.map((r) => r.name), + inverse: true, + }, + visualMap: { + min: 0, + max: 1, + orient: 'horizontal', + left: 'center', + bottom: 0, + inRange: { + color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'], + }, + }, + series: [ + { + type: 'bar', + data: classroomOccupancy.map((r) => ({ + name: r.name, + value: r.occupancy, + scheduleDays: r.scheduleDays, + rentalCount: r.rentalCount, + occupancy: r.occupancy, + })), + itemStyle: { borderRadius: [0, 4, 4, 0] }, + label: { + show: true, + position: 'right', + formatter: (p: { data: { occupancy: number } }) => + `${(p.data.occupancy * 100).toFixed(0)}%`, + }, + }, + ], + }; +} + +export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { + return { + tooltip: { + formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => + `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${p.data.value[2]}`, + }, + grid: { left: 100, right: 30, bottom: 40, top: 20 }, + xAxis: { type: 'time' }, + yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true }, + dataZoom: [ + { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 }, + { type: 'inside', xAxisIndex: 0 }, + ], + series: [ + { + type: 'custom', + renderItem: ( + _params: unknown, + api: { + value: (i: number) => string | boolean; + coord: (p: [string | number, string | number]) => [number, number]; + size: (p: [number, number]) => [number, number]; + }, + ) => { + const cat = String(api.value(0)); + const startDate = String(api.value(1)); + const endDate = String(api.value(2)); + const isActive = Boolean(api.value(3)); + const start = api.coord([startDate, cat]); + const end = api.coord([endDate, cat]); + const height = api.size([0, 1])[1] * 0.6; + const rectShape = { + x: start[0], + y: start[1] - height / 2, + width: Math.max(end[0] - start[0], 2), + height, + }; + return { + type: 'rect' as const, + shape: rectShape, + style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 }, + }; + }, + encode: { x: [1, 2], y: 0 }, + data: ganttData.flatMap((r) => + (r.occupancies || []).map((o) => ({ + name: o.studentName, + value: [ + r.roomNumber, + o.checkInDate, + o.checkOutDate || new Date().toISOString().slice(0, 10), + !o.checkOutDate, + ] as [string, string, string, boolean], + })), + ), + }, + ], + }; +} diff --git a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx new file mode 100644 index 0000000..f2f8554 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx @@ -0,0 +1,85 @@ +import React, { type CSSProperties } from 'react'; +import { Card, Col, Row } from 'antd'; +import { useIntersectionObserver } from 'usehooks-ts'; +import ReactECharts from '../../components/ECharts'; +import type { ClassroomOccupancy, GanttRoom } from './Dashboard.types'; +import { buildClassroomHeatmapOption, buildGanttOption } from './DashboardCharts'; + +const useInViewport = (rootMargin = '200px') => { + const { ref, isIntersecting } = useIntersectionObserver({ + rootMargin, + freezeOnceVisible: true, + }); + return { ref, inView: isIntersecting }; +}; + +const LazySection: React.FC<{ + title: string; + vp: { ref: (node?: Element | null) => void; inView: boolean }; + minHeight: number; + style?: CSSProperties; + children: React.ReactNode; +}> = ({ title, vp, minHeight, style, children }) => { + return ( +
+ {vp.inView ? ( + +
+ {children} + + + ) : ( + +
加载中…
+
+ )} + + ); +}; + +export const ClassroomHeatmapCard: React.FC<{ + data: ClassroomOccupancy[]; + isMobile: boolean; +}> = ({ data, isMobile }) => { + const vp = useInViewport('200px'); + return ( + + {data.length > 0 ? ( + + ) : ( +
暂无教室数据
+ )} +
+ ); +}; + +export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({ + data, + isMobile, +}) => { + const vp = useInViewport('200px'); + return ( + + {data.length > 0 ? ( + + ) : ( +
暂无入住数据
+ )} +
+ ); +}; diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx new file mode 100644 index 0000000..9b71270 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Card, Col, Row } from 'antd'; +import { + ArrowRightOutlined, + BankOutlined, + DollarOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router'; +import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types'; + +export const DashboardTodoCards: React.FC<{ + absentCount: number; + draftCount: number; + draftTotal: number; + pendingDeposits: number; +}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => { + const navigate = useNavigate(); + return ( + + + + 0 ? TODO_CARD_WARN : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/attendance')} + > +
+ 0 ? '#FF9500' : '#999' }} + /> + +
+
+
0 ? '#FF9500' : '#999', + }} + > + {absentCount} +
+
今日缺勤人数
+ {absentCount > 0 ? ( +
需要关注
+ ) : ( +
全员到齐
+ )} +
+
+ + + + 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/bills')} + > +
+ 0 ? '#AF52DE' : '#999' }} + /> + +
+
+
0 ? '#AF52DE' : '#999', + }} + > + {draftCount} +
+
待处理账单
+
0 ? '#AF52DE' : '#999', marginTop: 4 }} + > + {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} +
+
+
+ + + + 0 ? TODO_CARD_DANGER : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/deposits')} + > +
+ 0 ? '#FF3B30' : '#999' }} + /> + +
+
+
0 ? '#FF3B30' : '#999', + }} + > + ¥{pendingDeposits.toLocaleString()} +
+
待退押金
+
0 ? '#FF3B30' : '#999', + marginTop: 4, + }} + > + {pendingDeposits > 0 ? '需要处理' : '暂无待退'} +
+
+
+ + + + ); +}; diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 94f8a10..055555c 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -1,4 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { + classAttendanceRankingSchema, + classroomOccupanciesSchema, + classroomUtilStatsSchema, + dashboardStatsSchema, + expenseTypesSchema, + ganttRoomsSchema, + roomRankingSchema, +} from '../../api/schemas'; import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd'; import { TeamOutlined, @@ -11,460 +22,142 @@ import { FileProtectOutlined, ReadOutlined, CalendarOutlined, - ArrowRightOutlined, - ExclamationCircleOutlined, - DollarOutlined, } from '@ant-design/icons'; -import ReactECharts, { type EChartsOption } from '../../components/ECharts'; +import ReactECharts from '../../components/ECharts'; import dayjs from 'dayjs'; -import { useNavigate } from 'react-router-dom'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { + buildAttendanceLineOption, + buildAttendanceRingOption, + buildClassRankingOption, + buildExpensePieOption, + buildIncomeLineOption, + buildRoomRankingBarOption, +} from './DashboardCharts'; +import { ClassroomHeatmapCard, GanttCard } from './DashboardLazyCards'; +import { + MARGIN_BOTTOM_16_STYLE, + SECTION_ROW_STYLE, + type ClassAttendanceRank, + type ClassroomOccupancy, + type ClassroomUtilStats, + type DashboardStats, + type GanttRoom, +} from './Dashboard.types'; +import { DashboardTodoCards } from './DashboardTodoCards'; const { RangePicker } = DatePicker; -const COLORS = [ - '#007AFF', - '#34C759', - '#FF9500', - '#FF3B30', - '#5AC8FA', - '#AF52DE', - '#FF2D55', - '#FFCC00', -]; - -interface BillStatRow { - status: string; - count: string; - total: string; -} -interface ClassAttendanceRank { - className: string; - present: number; - total: number; - rate: number; -} -interface ClassroomOccupancy { - name: string; - building: string; - capacity: number; - scheduleDays: number; - rentalCount: number; - occupancy: number; -} -interface ClassroomUtilStats { - totalClassrooms: number; - inUseCount: number; - utilizationRate: string; - scheduleCount: number; - rentalCount: number; -} -interface AttendanceTrendRow { - date: string; - rate: string; -} -interface IncomeTrendRow { - month: string; - amount: number; -} -interface OccupancyByBuildingRow { - building: string; - count: string; -} -interface ExpenseByTypeRow { - type: string; - total: string; -} -interface GanttOccupancy { - studentName: string; - studentId?: string; - checkInDate: string; - checkOutDate: string | null; - billingStartDate?: string; - billingEndDate?: string; -} -interface GanttRoom { - roomNumber: string; - occupancies: GanttOccupancy[]; -} - -interface DashboardStats { - totalRooms: number; - totalStudents: number; - occupiedBeds: number; - totalCapacity: number; - occupancyRate: string; - billStats: BillStatRow[]; - classroomCount: number; - classroomOccupancyRate: string; - todayAttendanceRate: string; - monthlyIncome: number; - classCount: number; - teacherCount: number; - pendingDeposits: number; - activeRentals: number; - todayPresent: number; - occupancyByBuilding: OccupancyByBuildingRow[]; - attendanceByStatus: Record; - expenseByType: ExpenseByTypeRow[]; - attendanceTrend: AttendanceTrendRow[]; - incomeTrend: IncomeTrendRow[]; -} - -const attendanceLabelMap: Record = { - present: '出勤', - absent: '缺勤', - late: '迟到', - early: '早退', - leave: '请假', -}; - -const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 }; -const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 }; - -// ─── 待办卡片样式 ─── -const TODO_CARD_BASE: React.CSSProperties = { - cursor: 'pointer', - transition: 'box-shadow 0.2s, transform 0.2s', - borderRadius: 8, - height: '100%', -}; -const TODO_CARD_WARN: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #FF9500', - background: '#fff7e6', -}; -const TODO_CARD_DANGER: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #FF3B30', - background: '#fff1f0', -}; -const TODO_CARD_OK: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #34C759', - background: '#f0fff4', -}; -const TODO_CARD_DRAFT: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #AF52DE', - background: '#f9f0ff', -}; - -// ─── IntersectionObserver 自定义 hook ─── -// 用 callback ref 注册 observer,避免元素在首屏 loading 后才挂载、 -// 而 effect 因依赖不变不再重跑导致 observer 从未注册的问题。 -const useInViewport = (rootMargin = '200px') => { - const [inView, setInView] = useState(false); - const observerRef = useRef(null); - - const ref = useCallback( - (el: HTMLDivElement | null) => { - observerRef.current?.disconnect(); - if (!el) return; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setInView(true); - observer.disconnect(); - } - }, - { rootMargin }, - ); - observer.observe(el); - observerRef.current = observer; - }, - [rootMargin], - ); - - return { ref, inView }; -}; - const DashboardPage: React.FC = () => { const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; - const navigate = useNavigate(); - const [stats, setStats] = useState(null); - const [classRanking, setClassRanking] = useState<{ - top: ClassAttendanceRank[]; - bottom: ClassAttendanceRank[]; - }>({ top: [], bottom: [] }); - const [classroomOccupancy, setClassroomOccupancy] = useState([]); - const [ganttData, setGanttData] = useState([]); - const [roomRanking, setRoomRanking] = useState>([]); - const [classroomUtil, setClassroomUtil] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshLoading, setRefreshLoading] = useState(false); - const loadedRef = useRef(false); const [period, setPeriod] = useState<[string, string]>([ dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD'), ]); - const fetchData = useCallback(async () => { - const isRefresh = loadedRef.current; - if (isRefresh) { - setRefreshLoading(true); - } else { - setLoading(true); - } - try { - const [s, rr, cr, g, co, cu] = await Promise.all([ - api.get('/dashboard/stats'), - api.get>('/dashboard/room-ranking', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( - '/dashboard/class-attendance-ranking', - ), - api.get('/dashboard/gantt', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get('/dashboard/classroom-occupancy'), - api.get('/dashboard/classroom-utilization'), - ]); - setStats(s); - setRoomRanking(rr); - setClassRanking(cr); - setGanttData(g); - setClassroomOccupancy(co); - setClassroomUtil(cu); - loadedRef.current = true; - } catch (e) { - console.error(e); - message.error('数据加载失败,请稍后重试'); - } - setLoading(false); - setRefreshLoading(false); - }, [period]); + const { + data: fetchResult = { + stats: null, + classRanking: { top: [], bottom: [] }, + classroomOccupancy: [], + ganttData: [], + roomRanking: [], + classroomUtil: null, + }, + isLoading, + isFetching, + } = useQuery<{ + stats: DashboardStats | null; + classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }; + classroomOccupancy: ClassroomOccupancy[]; + ganttData: GanttRoom[]; + roomRanking: Array<{ roomNumber: string; total: string }>; + classroomUtil: ClassroomUtilStats | null; + }>({ + queryKey: ['dashboard', period], + queryFn: async () => { + try { + const [s, rr, cr, g, co, cu] = await Promise.all([ + api.get('/dashboard/stats'), + api.get>('/dashboard/room-ranking', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( + '/dashboard/class-attendance-ranking', + ), + api.get('/dashboard/gantt', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get('/dashboard/classroom-occupancy'), + api.get('/dashboard/classroom-utilization'), + ]); + return { + stats: validateResponse(dashboardStatsSchema, s), + roomRanking: validateResponse>( + roomRankingSchema, + rr, + ), + classRanking: validateResponse<{ + top: ClassAttendanceRank[]; + bottom: ClassAttendanceRank[]; + }>(classAttendanceRankingSchema, cr), + ganttData: validateResponse(ganttRoomsSchema, g), + classroomOccupancy: validateResponse( + classroomOccupanciesSchema, + co, + ), + classroomUtil: validateResponse(classroomUtilStatsSchema, cu), + }; + } catch (e) { + console.error(e); + message.error('数据加载失败,请稍后重试'); + return { + stats: null, + classRanking: { top: [], bottom: [] }, + classroomOccupancy: [], + ganttData: [], + roomRanking: [], + classroomUtil: null, + }; + } + }, + }); + const stats = fetchResult.stats; + const classRanking = fetchResult.classRanking; + const classroomOccupancy = fetchResult.classroomOccupancy; + const ganttData = fetchResult.ganttData; + const roomRanking = fetchResult.roomRanking; + const classroomUtil = fetchResult.classroomUtil; + const loading = isLoading; + const refreshLoading = isFetching && !isLoading; - useEffect(() => { - fetchData(); - }, [fetchData]); - - const [expenseTypeMap, setExpenseTypeMap] = useState>({}); - - useEffect(() => { - api - .get>('/expense-types') - .then((types) => { + const { data: expenseTypeMap = {} } = useQuery>({ + queryKey: ['expense-types', 'map'], + queryFn: async () => { + try { + const types = validateResponse>( + expenseTypesSchema, + await api.get>('/expense-types'), + ); const map: Record = {}; for (const t of types) map[t.code] = t.name; - setExpenseTypeMap(map); - }) - .catch(() => {}); - }, []); - - // ─── 图表 option 计算(保留全部原有逻辑) ─── - - // 今日出勤状态分布环图 - const attendanceRingOption = useMemo( - () => ({ - tooltip: { trigger: 'item' }, - legend: { bottom: 0 }, - series: [ - { - type: 'pie', - radius: ['40%', '70%'], - center: ['50%', '45%'], - data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ - name: attendanceLabelMap[status] ?? status, - value: count, - })), - itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, - }, - ], - color: COLORS, - }), - [stats?.attendanceByStatus], - ); - - // 宿舍费用排行 - const barOption = useMemo( - () => ({ - tooltip: {}, - grid: { left: 80, right: 20, bottom: 30, top: 10 }, - xAxis: { type: 'value' }, - yAxis: { - type: 'category', - data: roomRanking.map((r) => r.roomNumber).reverse(), - inverse: false, - }, - series: [ - { - type: 'bar', - data: roomRanking.map((r) => Number(r.total)).reverse(), - itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] }, - }, - ], - }), - [roomRanking], - ); - - // 班级考勤排行 - 前5 - const classRankingTopOption: EChartsOption = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (v: number) => `${v}%`, + return map; + } catch { + return {}; + } }, - grid: { left: 80, right: 30, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, - yAxis: { - type: 'category', - data: classRanking.top.map((r) => r.className), - inverse: true, - }, - series: [ - { - type: 'bar', - data: classRanking.top.map((r) => r.rate), - itemStyle: { color: '#34C759', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'right', formatter: '{c}%' }, - }, - ], - }; + }); - // 班级考勤排行 - 后5 - const classRankingBottomOption: EChartsOption = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (v: number) => `${v}%`, - }, - grid: { left: 80, right: 30, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, - yAxis: { - type: 'category', - data: classRanking.bottom.map((r) => r.className), - inverse: true, - }, - series: [ - { - type: 'bar', - data: classRanking.bottom.map((r) => r.rate), - itemStyle: { color: '#FF3B30', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'right', formatter: '{c}%' }, - }, - ], - }; - - // 考勤趋势折线图 - const attendanceLineOption: EChartsOption = { - tooltip: { trigger: 'axis' }, - grid: { left: 50, right: 20, bottom: 30, top: 10 }, - xAxis: { - type: 'category', - data: (stats?.attendanceTrend || []).map((d: { date: string }) => d.date), - axisLabel: { rotate: 45, fontSize: 10 }, - }, - yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, - series: [ - { - type: 'line', - data: (stats?.attendanceTrend || []).map((d: { rate: string }) => parseFloat(d.rate) || 0), - smooth: true, - lineStyle: { color: '#007AFF', width: 2 }, - itemStyle: { color: '#007AFF' }, - areaStyle: { color: 'rgba(0,122,255,0.1)' }, - }, - ], - }; - - // 收入趋势折线图 - const incomeLineOption: EChartsOption = { - tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, - grid: { left: 70, right: 20, bottom: 30, top: 10 }, - xAxis: { - type: 'category', - data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month), - }, - yAxis: { - type: 'value', - axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` }, - }, - series: [ - { - type: 'line', - data: (stats?.incomeTrend || []).map((d: { amount: number }) => d.amount), - smooth: true, - lineStyle: { color: '#34C759', width: 2 }, - itemStyle: { color: '#34C759' }, - areaStyle: { color: 'rgba(52,199,89,0.1)' }, - }, - ], - }; - - // 入住时间线(甘特图) - const ganttOption = useMemo( - () => ({ - tooltip: { - formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => - `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${p.data.value[2]}`, - }, - grid: { left: 100, right: 30, bottom: 40, top: 20 }, - xAxis: { type: 'time' }, - yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true }, - dataZoom: [ - { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 }, - { type: 'inside', xAxisIndex: 0 }, - ], - series: [ - { - type: 'custom', - renderItem: ( - _params: unknown, - api: { - value: (i: number) => string | boolean; - coord: (p: [string | number, string | number]) => [number, number]; - size: (p: [number, number]) => [number, number]; - }, - ) => { - const [cat, startDate, endDate, isActive] = [ - api.value(0), - api.value(1), - api.value(2), - api.value(3), - ] as unknown as [string, string, string, boolean]; - const start = api.coord([startDate, cat]); - const end = api.coord([endDate, cat]); - const height = api.size([0, 1])[1] * 0.6; - const rectShape = { - x: start[0], - y: start[1] - height / 2, - width: Math.max(end[0] - start[0], 2), - height, - }; - return { - type: 'rect' as const, - shape: rectShape, - style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 }, - }; - }, - encode: { x: [1, 2], y: 0 }, - data: ganttData.flatMap((r) => - (r.occupancies || []).map((o) => ({ - name: o.studentName, - value: [ - r.roomNumber, - o.checkInDate, - o.checkOutDate || new Date().toISOString().slice(0, 10), - !o.checkOutDate, - ] as [string, string, string, boolean], - })), - ), - }, - ], - }), - [ganttData], - ); - - // ─── 懒加载 hooks ─── - const classroomHeatmapVp = useInViewport('200px'); - const ganttVp = useInViewport('200px'); - - // ─── 待办卡片数据 ─── const absentCount = stats?.attendanceByStatus?.['absent'] ?? 0; + const attendanceTotal = stats + ? Object.values(stats.attendanceByStatus).reduce((sum, n) => sum + Number(n || 0), 0) + : 0; + const presentCount = stats?.attendanceByStatus?.present ?? 0; + const todayAttendanceRate = + stats?.todayAttendanceRate ?? + (attendanceTotal > 0 ? ((presentCount / attendanceTotal) * 100).toFixed(1) : '0'); const draftBill = (stats?.billStats ?? []).find((b) => b.status === 'draft'); const draftCount = draftBill ? Number(draftBill.count) : 0; const draftTotal = draftBill ? Number(draftBill.total) : 0; @@ -499,118 +192,12 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 待办与异常 ═══════════ */} - - - {/* 今日缺勤 */} -
- 0 ? TODO_CARD_WARN : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/attendance')} - > -
- 0 ? '#FF9500' : '#999' }} - /> - -
-
-
0 ? '#FF9500' : '#999', - }} - > - {absentCount} -
-
今日缺勤人数
- {absentCount > 0 ? ( -
需要关注
- ) : ( -
全员到齐
- )} -
-
- - - {/* 待处理账单 */} - - 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/bills')} - > -
- 0 ? '#AF52DE' : '#999' }} - /> - -
-
-
0 ? '#AF52DE' : '#999', - }} - > - {draftCount} -
-
待处理账单
-
0 ? '#AF52DE' : '#999', marginTop: 4 }} - > - {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} -
-
-
- - - {/* 待退押金 */} - - 0 ? TODO_CARD_DANGER : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/deposits')} - > -
- 0 ? '#FF3B30' : '#999' }} - /> - -
-
-
0 ? '#FF3B30' : '#999', - }} - > - ¥{pendingDeposits.toLocaleString()} -
-
待退押金
-
0 ? '#FF3B30' : '#999', - marginTop: 4, - }} - > - {pendingDeposits > 0 ? '需要处理' : '暂无待退'} -
-
-
- - - + {/* ═══════════ 核心 KPI ═══════════ */} @@ -638,7 +225,7 @@ const DashboardPage: React.FC = () => { } /> @@ -809,7 +396,7 @@ const DashboardPage: React.FC = () => { {(stats?.attendanceTrend || []).length > 0 ? ( ) : ( @@ -821,7 +408,7 @@ const DashboardPage: React.FC = () => { {Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( ) : ( @@ -837,7 +424,7 @@ const DashboardPage: React.FC = () => { {classRanking.top.length > 0 ? ( ) : ( @@ -849,7 +436,7 @@ const DashboardPage: React.FC = () => { {classRanking.bottom.length > 0 ? ( ) : ( @@ -865,24 +452,7 @@ const DashboardPage: React.FC = () => { {(stats?.expenseByType ?? []).length > 0 ? ( ({ - name: expenseTypeMap[e.type] ?? e.type, - value: Number(e.total), - })), - }, - ], - } satisfies EChartsOption - } + option={buildExpensePieOption(stats?.expenseByType ?? [], expenseTypeMap)} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> ) : ( @@ -894,7 +464,7 @@ const DashboardPage: React.FC = () => { {roomRanking.length > 0 ? ( ) : ( @@ -910,7 +480,7 @@ const DashboardPage: React.FC = () => { {(stats?.incomeTrend || []).length > 0 ? ( ) : ( @@ -921,102 +491,10 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */} -
- {classroomHeatmapVp.inView ? ( - -
- - {classroomOccupancy.length > 0 ? ( - - `${p.name}
排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, - }, - grid: { left: 100, right: 20, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 1 }, - yAxis: { - type: 'category', - data: classroomOccupancy.map((r) => r.name), - inverse: true, - }, - visualMap: { - min: 0, - max: 1, - orient: 'horizontal', - left: 'center', - bottom: 0, - inRange: { - color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'], - }, - }, - series: [ - { - type: 'bar', - data: classroomOccupancy.map((r) => ({ - name: r.name, - value: r.occupancy, - scheduleDays: r.scheduleDays, - rentalCount: r.rentalCount, - occupancy: r.occupancy, - })), - itemStyle: { borderRadius: [0, 4, 4, 0] }, - label: { - show: true, - position: 'right', - formatter: (p: { data: { occupancy: number } }) => - `${(p.data.occupancy * 100).toFixed(0)}%`, - }, - }, - ], - } satisfies EChartsOption - } - style={{ width: '100%', height: isMobile ? 300 : 400 }} - /> - ) : ( -
- 暂无教室数据 -
- )} -
- - - ) : ( - -
加载中…
-
- )} - + {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} -
- {ganttVp.inView ? ( - -
- - {ganttData.length > 0 ? ( - - ) : ( -
- 暂无入住数据 -
- )} -
- - - ) : ( - -
加载中…
-
- )} - + ); }; diff --git a/apps/admin/src/pages/Deposits/DepositModals.tsx b/apps/admin/src/pages/Deposits/DepositModals.tsx new file mode 100644 index 0000000..9a78039 --- /dev/null +++ b/apps/admin/src/pages/Deposits/DepositModals.tsx @@ -0,0 +1,423 @@ +import React from 'react'; +import { + Card, + DatePicker, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, +} from 'antd'; +import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import type { DepositStudentLookup } from './deposit-student-option'; + +export interface DepositRecord { + id: number; + studentId: number; + amount: number; + status: string; + paidDate: string; + refundDate?: string | null; + notes?: string | null; + installments?: Array<{ + id: number; + amount: number; + dueDate: string; + paidDate?: string | null; + status: string; + }>; + student?: DepositStudentLookup; +} + +export interface EligibleStudent { + studentId: number; + studentName: string; + studentNo?: string | null; + roomId: number; + roomNumber: string; + building?: string | null; + roomType?: string | null; + capacity: number; + depositAmount: number; +} + +export const statusMap: Record = { + paid: { text: '有余额', color: 'green' }, + refunded: { text: '已全退', color: 'blue' }, + depleted: { text: '已扣完', color: 'red' }, +}; + +export const installmentStatusMap: Record = { + pending: { text: '待缴', color: 'orange' }, + paid: { text: '已缴', color: 'green' }, +}; + +export const roomTypeOptions = [ + { value: '单人间', label: '单人间' }, + { value: '四人间', label: '四人间' }, +]; + +export const suggestedDepositByRoomType: Record = { + 单人间: 200, + 四人间: 100, +}; + +export interface DepositModalsProps { + batchModal: boolean; + createModal: boolean; + refundModal: DepositRecord | null; + detailModal: DepositRecord | null; + installmentModal: number | null; + batchForm: ReturnType[0]; + createForm: ReturnType[0]; + refundForm: ReturnType[0]; + installmentForm: ReturnType[0]; + saving: boolean; + batchRoomType: string; + effectiveSelectedEligibleIds: number[]; + eligibleStudents: EligibleStudent[]; + eligibleLoading: boolean; + eligibleColumns: Array<{ title: string; render?: unknown; dataIndex?: string }>; + studentOptions: Array<{ value: number; label: string }>; + onBatchRoomTypeChange: (roomType: string) => void; + onBatchCreate: () => void; + onCreate: () => void; + onRefund: () => void; + onAddInstallment: () => void; + onPayInstallment: (installmentId: number) => void; + onSaveInstallmentCell: ( + installmentId: number, + field: 'status' | 'paidDate', + value: unknown, + ) => void; + onDeleteInstallment: (installmentId: number) => void; + onCloseBatch: () => void; + onCloseCreate: () => void; + onCloseRefund: () => void; + onCloseDetail: () => void; + onCloseInstallment: () => void; + onOpenInstallment: (id: number) => void; + onSelectEligible: (ids: number[]) => void; +} + +export const DepositModals: React.FC = ({ + batchModal, + createModal, + refundModal, + detailModal, + installmentModal, + batchForm, + createForm, + refundForm, + installmentForm, + saving, + batchRoomType, + effectiveSelectedEligibleIds, + eligibleStudents, + eligibleLoading, + eligibleColumns, + studentOptions, + onBatchRoomTypeChange, + onBatchCreate, + onCreate, + onRefund, + onAddInstallment, + onPayInstallment, + onSaveInstallmentCell, + onDeleteInstallment, + onCloseBatch, + onCloseCreate, + onCloseRefund, + onCloseDetail, + onCloseInstallment, + onOpenInstallment, + onSelectEligible, +}) => { + return ( + <> + +
+ + +
}} + pagination={{ pageSize: 6, showSizeChanger: false }} + rowSelection={{ + selectedRowKeys: effectiveSelectedEligibleIds, + onChange: (keys) => onSelectEligible(keys as number[]), + }} + /> + + + + + +
`¥${value.toFixed(2)}`, + }, + { title: '到期日', dataIndex: 'dueDate' }, + { + title: '实付日', + dataIndex: 'paidDate', + render: (value: string, item: any) => ( + + onSaveInstallmentCell(item.id, 'paidDate', next) + } + > + {value || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + render: (value: string, item: any) => ( + + onSaveInstallmentCell(item.id, 'status', next) + } + > + + {installmentStatusMap[value]?.text || value} + + + ), + }, + { + title: '操作', + render: (_: unknown, item: any) => ( + + {item.status === 'pending' && ( + } + onClick={() => onPayInstallment(item.id)} + > + 标记已缴 + + )} + onDeleteInstallment(item.id)} + > + } + > + 归档 + + + + ), + }, + ]} + /> + ) : ( +

暂无分期记录

+ )} + + )} + + + + + + + + + + + + + + ); +}; diff --git a/apps/admin/src/pages/Deposits/DepositTable.tsx b/apps/admin/src/pages/Deposits/DepositTable.tsx new file mode 100644 index 0000000..e0db067 --- /dev/null +++ b/apps/admin/src/pages/Deposits/DepositTable.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Button, Empty, Popconfirm, Space, Table, Tag } from 'antd'; +import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import { message } from '../../ui/app-message'; +import { statusMap } from './DepositModals'; +import type { DepositRecord } from './DepositModals'; + +export interface DepositTableProps { + data: any[]; + loading: boolean; + canPurgeDeposit: boolean; + refundForm: ReturnType[0]; + onDetail: (record: DepositRecord) => void; + onRefund: (record: DepositRecord) => void; + onArchive: (id: number) => Promise | unknown; + onPurge: (id: number) => Promise | unknown; +} + +export const DepositTable: React.FC = ({ + data, + loading, + canPurgeDeposit, + refundForm, + onDetail, + onRefund, + onArchive, + onPurge, +}) => { + const columns = [ + { title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' }, + { + title: '当前可用押金', + dataIndex: 'amount', + width: 130, + render: (v: number) => `¥${Number(v || 0).toFixed(2)}`, + }, + { + title: '房间', + width: 120, + render: (_: unknown, r: any) => + r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-', + }, + { title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' }, + { title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' }, + { + title: '状态', + dataIndex: 'status', + render: (s: string) => + s === 'unpaid' ? ( + 未缴 + ) : ( + {statusMap[s]?.text || s} + ), + }, + { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' }, + { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, + { + title: '操作', + width: 240, + render: (_: unknown, record: any) => { + const hasDeposit = typeof record.id === 'number'; + return ( + + {hasDeposit && ( + onDetail(record)}> + 详情 + + )} + {record.status === 'paid' && hasDeposit && ( + { + onRefund(record); + refundForm.setFieldsValue({ refundDate: dayjs() }); + }} + > + 退还 + + )} + {hasDeposit && ( + { + try { + await onArchive(record.id); + message.success('归档成功'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + } + > + 归档 + + + )} + {record.status === 'archived' && hasDeposit && canPurgeDeposit ? ( + { + try { + await onPurge(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + + + ) : null} + + ); + }, + }, + ]; + + return ( +
`共 ${total} 条`, + }} + locale={{ emptyText: }} + /> + ); +}; diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 5549800..60babc3 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -1,89 +1,41 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useCallback, useMemo, useState } from 'react'; import { - Table, - Modal, Form, - Select, - DatePicker, - InputNumber, Input, + Select, Space, - Tag, - Popconfirm, - Card, - Empty, } from 'antd'; -import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons'; +import { PlusOutlined, TeamOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option'; - -const statusMap: Record = { - paid: { text: '有余额', color: 'green' }, - refunded: { text: '已全退', color: 'blue' }, - depleted: { text: '已扣完', color: 'red' }, -}; - -const installmentStatusMap: Record = { - pending: { text: '待缴', color: 'orange' }, - paid: { text: '已缴', color: 'green' }, -}; - -const roomTypeOptions = [ - { value: '单人间', label: '单人间' }, - { value: '四人间', label: '四人间' }, -]; - -const suggestedDepositByRoomType: Record = { - 单人间: 200, - 四人间: 100, -}; - -interface DepositRecord { - id: number; - studentId: number; - amount: number; - status: string; - paidDate: string; - refundDate?: string | null; - notes?: string | null; - installments?: Array<{ - id: number; - amount: number; - dueDate: string; - paidDate?: string | null; - status: string; - }>; - student?: DepositStudentLookup; -} - -interface EligibleStudent { - studentId: number; - studentName: string; - studentNo?: string | null; - roomId: number; - roomNumber: string; - building?: string | null; - roomType?: string | null; - capacity: number; - depositAmount: number; -} - -const isFormValidationError = (error: unknown) => - typeof error === 'object' && - error !== null && - Array.isArray((error as { errorFields?: unknown }).errorFields); +import { usePermission } from '../../hooks/usePermission'; +import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { + depositStudentLookupsSchema, + depositsSchema, + eligibleStudentsSchema, +} from '../../api/schemas'; +import { + DepositModals, + roomTypeOptions, + suggestedDepositByRoomType, +} from './DepositModals'; +import type { DepositRecord, EligibleStudent } from './DepositModals'; +import { DepositTable } from './DepositTable'; const DepositsPage: React.FC = () => { - const [data, setData] = useState([]); - const [students, setStudents] = useState([]); - const [eligibleStudents, setEligibleStudents] = useState([]); + const { hasPermission } = usePermission(); + const canPurgeDeposit = hasPermission('deposit:purge'); const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState([]); - const [loading, setLoading] = useState(false); - const [eligibleLoading, setEligibleLoading] = useState(false); + const [selectionTouched, setSelectionTouched] = useState(false); + const [eligibleRoomType, setEligibleRoomType] = useState(undefined); + const queryClient = useQueryClient(); const [createModal, setCreateModal] = useState(false); const [batchModal, setBatchModal] = useState(false); const [refundModal, setRefundModal] = useState(null); @@ -99,47 +51,115 @@ const DepositsPage: React.FC = () => { const [batchRoomType, setBatchRoomType] = useState('四人间'); const [saving, setSaving] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [d, s] = await Promise.all([ - api.get('/deposits'), - api.get('/deposits/student-lookups'), - ]); - setData(d); - setStudents(s); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } finally { - setLoading(false); - } - }, []); + const { + data: fetchResult = { data: [], students: [] }, + isLoading, + isFetching, + } = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({ + queryKey: ['deposits'], + queryFn: async () => { + try { + const [d, s] = await Promise.all([ + api.get('/deposits'), + api.get('/deposits/student-lookups'), + ]); + return { + data: validateResponse(depositsSchema, d), + students: validateResponse(depositStudentLookupsSchema, s), + }; + } catch (e: any) { + message.error(e?.message || '加载失败'); + return { data: [], students: [] }; + } + }, + }); + const data = fetchResult.data; + const students = fetchResult.students; + const loading = isLoading || isFetching; - const fetchEligibleStudents = useCallback(async (roomType?: string) => { - setEligibleLoading(true); - try { - const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : ''; - const rows = await api.get(`/deposits/eligible-students${params}`); - setEligibleStudents(rows); - setSelectedEligibleStudentIds(rows.map((item) => item.studentId)); - } catch (e: any) { - message.error(e?.message || '加载在住人员失败'); - } finally { - setEligibleLoading(false); - } - }, []); + const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']]; + const createMutation = useApiMutation( + async (payload: Record) => api.post('/deposits', payload), + { invalidate: invalidateDeposits }, + ); + const batchCreateMutation = useApiMutation( + async (payload: Record) => api.post('/deposits/batch', payload), + { invalidate: invalidateDeposits }, + ); + const refundMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.put(`/deposits/${id}/refund`, payload), + { invalidate: invalidateDeposits }, + ); + const addInstallmentMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.post(`/deposits/${id}/installments`, payload), + { invalidate: [['deposits']] }, + ); + const payInstallmentMutation = useApiMutation( + async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`), + { invalidate: [['deposits']] }, + ); + const saveInstallmentCellMutation = useApiMutation( + async ({ + installmentId, + field, + value, + }: { + installmentId: number; + field: 'status' | 'paidDate'; + value: unknown; + }) => api.put(`/deposits/installments/${installmentId}`, { [field]: value }), + { invalidate: [['deposits']] }, + ); + const deleteInstallmentMutation = useApiMutation( + async (installmentId: number) => api.delete(`/deposits/installments/${installmentId}`), + { invalidate: [['deposits']] }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/deposits/${id}`), + { invalidate: invalidateDeposits }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/deposits/${id}/permanent`), + { invalidate: invalidateDeposits }, + ); - useEffect(() => { - fetchData(); - }, [fetchData]); + const { + data: eligibleStudents = [], + isFetching: eligibleFetching, + } = useQuery({ + queryKey: ['deposits', 'eligible', eligibleRoomType], + queryFn: async () => { + const params: Record = {}; + if (eligibleRoomType) params.roomType = eligibleRoomType; + return validateResponse( + eligibleStudentsSchema, + await api.get('/deposits/eligible', { params }), + ); + }, + }); + const eligibleLoading = eligibleFetching; + const effectiveSelectedEligibleIds = selectionTouched + ? selectedEligibleStudentIds + : eligibleStudents.map((item) => item.studentId); + const fetchEligibleStudents = useCallback( + (roomType?: string) => { + setEligibleRoomType(roomType); + queryClient.invalidateQueries({ queryKey: ['deposits', 'eligible'] }); + }, + [queryClient], + ); - useEffect(() => { - fetchEligibleStudents(filterRoomType); - }, [fetchEligibleStudents, filterRoomType]); + const changeFilterRoomType = (value: string | undefined) => { + setFilterRoomType(value); + setSelectionTouched(false); + fetchEligibleStudents(value); + }; const depositByStudentId = useMemo(() => { const map = new Map(); - data.forEach((item) => map.set(item.studentId, item)); + for (const item of data) map.set(item.studentId, item); return map; }, [data]); @@ -176,7 +196,6 @@ const DepositsPage: React.FC = () => { }; }); } - return data.filter((d) => { if (searchText) { const s = searchText.toLowerCase(); @@ -192,10 +211,11 @@ const DepositsPage: React.FC = () => { const openBatchModal = (roomType = filterRoomType || '四人间') => { const amount = suggestedDepositByRoomType[roomType] ?? 100; setBatchRoomType(roomType); + setSelectionTouched(false); + fetchEligibleStudents(roomType); batchForm.resetFields(); batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() }); setBatchModal(true); - fetchEligibleStudents(roomType); }; const handleBatchRoomTypeChange = (roomType: string) => { @@ -207,55 +227,38 @@ const DepositsPage: React.FC = () => { }; const handleCreate = async () => { - setSaving(true); try { const values = await createForm.validateFields(); - await api.post('/deposits', { + await createMutation.mutateAsync({ studentId: values.studentId, amount: values.amount, paidDate: values.paidDate.format('YYYY-MM-DD'), notes: values.notes, }); - message.success('押金金额已增加'); + message.success('押金收取成功'); setCreateModal(false); createForm.resetFields(); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } - } finally { - setSaving(false); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleBatchCreate = async () => { - if (selectedEligibleStudentIds.length === 0) { - message.warning('请选择至少一名学生'); - return; - } - setSaving(true); try { const values = await batchForm.validateFields(); - await api.post('/deposits/batch', { - studentIds: selectedEligibleStudentIds, + await batchCreateMutation.mutateAsync({ + studentIds: effectiveSelectedEligibleIds, amount: values.amount, paidDate: values.paidDate.format('YYYY-MM-DD'), notes: values.notes, roomType: values.roomType, }); - message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`); + message.success('批量收取成功'); setBatchModal(false); batchForm.resetFields(); - await fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } - } finally { - setSaving(false); + setSelectionTouched(false); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -264,19 +267,18 @@ const DepositsPage: React.FC = () => { setSaving(true); try { const values = await refundForm.validateFields(); - await api.put(`/deposits/${refundModal.id}/refund`, { - refundDate: values.refundDate.format('YYYY-MM-DD'), - notes: values.notes, + await refundMutation.mutateAsync({ + id: refundModal.id, + payload: { + refundDate: values.refundDate.format('YYYY-MM-DD'), + notes: values.notes, + }, }); message.success('退还操作完成'); setRefundModal(null); refundForm.resetFields(); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -286,31 +288,27 @@ const DepositsPage: React.FC = () => { if (installmentModal == null) return; try { const values = await installmentForm.validateFields(); - await api.post(`/deposits/${installmentModal}/installments`, { - amount: values.amount, - dueDate: values.dueDate.format('YYYY-MM-DD'), + await addInstallmentMutation.mutateAsync({ + id: installmentModal, + payload: { + amount: values.amount, + dueDate: values.dueDate.format('YYYY-MM-DD'), + }, }); message.success('分期已添加'); setInstallmentModal(null); installmentForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handlePayInstallment = async (installmentId: number) => { try { - await api.put(`/deposits/installments/${installmentId}`, { - paidDate: dayjs().format('YYYY-MM-DD'), - status: 'paid', - }); + await payInstallmentMutation.mutateAsync(installmentId); message.success('分期已标记为已缴'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -319,117 +317,27 @@ const DepositsPage: React.FC = () => { field: 'status' | 'paidDate', value: unknown, ) => { - await api.put(`/deposits/installments/${installmentId}`, { [field]: value }); - message.success('分期记录已保存'); - if (detailModal) { - const refreshed = await api.get(`/deposits/${detailModal.id}`); - setDetailModal(refreshed); + try { + await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value }); + message.success('分期记录已保存'); + if (detailModal) { + const refreshed = await api.get(`/deposits/${detailModal.id}`); + setDetailModal(refreshed); + } + } catch { + // 错误提示由 useApiMutation 统一处理 } - await fetchData(); }; const handleDeleteInstallment = async (installmentId: number) => { try { - await api.delete(`/deposits/installments/${installmentId}`); + await deleteInstallmentMutation.mutateAsync(installmentId); message.success('分期已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - const columns = useMemo( - () => [ - { title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' }, - { - title: '当前可用押金', - dataIndex: 'amount', - width: 130, - render: (v: number) => `¥${Number(v || 0).toFixed(2)}`, - }, - { - title: '房间', - width: 120, - render: (_: unknown, r: any) => - r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-', - }, - { title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' }, - { title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' }, - { - title: '状态', - dataIndex: 'status', - render: (s: string) => - s === 'unpaid' ? ( - 未缴 - ) : ( - {statusMap[s]?.text || s} - ), - }, - { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' }, - { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, - { - title: '操作', - width: 240, - render: (_: unknown, record: any) => { - const hasDeposit = typeof record.id === 'number'; - return ( - - {hasDeposit && ( - { - setDetailModal(record); - }} - > - 详情 - - )} - {record.status === 'paid' && hasDeposit && ( - { - setRefundModal(record); - refundForm.setFieldsValue({ refundDate: dayjs() }); - }} - > - 退还 - - )} - {hasDeposit && ( - { - try { - await api.delete(`/deposits/${record.id}`); - message.success('归档成功'); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - } - > - 归档 - - - )} - - ); - }, - }, - ], - [fetchData, fetchEligibleStudents, filterRoomType, refundForm], - ); - const eligibleColumns = [ { title: '学生', @@ -475,7 +383,7 @@ const DepositsPage: React.FC = () => { allowClear style={{ width: 130 }} value={filterRoomType} - onChange={(v) => setFilterRoomType(v)} + onChange={changeFilterRoomType} options={roomTypeOptions} />
`共 ${total} 条`, - }} - locale={{ emptyText: }} + canPurgeDeposit={canPurgeDeposit} + refundForm={refundForm} + onDetail={(record) => setDetailModal(record)} + onRefund={(record) => setRefundModal(record)} + onArchive={(id) => archiveMutation.mutateAsync(id)} + onPurge={(id) => purgeMutation.mutateAsync(id)} + /> + setBatchModal(false)} + onCloseCreate={() => setCreateModal(false)} + onCloseRefund={() => setRefundModal(null)} + onCloseDetail={() => setDetailModal(null)} + onCloseInstallment={() => setInstallmentModal(null)} + onOpenInstallment={(id) => { + setInstallmentModal(id); + installmentForm.resetFields(); + }} + onSelectEligible={(ids) => { + setSelectedEligibleStudentIds(ids); + setSelectionTouched(true); + }} /> - - {/* Batch Create Modal */} - setBatchModal(false)} - okText="确认批量收取" - confirmLoading={saving} - okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }} - width={760} - > -
- - -
}} - pagination={{ pageSize: 6, showSizeChanger: false }} - rowSelection={{ - selectedRowKeys: selectedEligibleStudentIds, - onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]), - }} - /> - - - {/* Create Modal */} - setCreateModal(false)} - okText="确认" - confirmLoading={saving} - > - - -
`¥${Number(value).toFixed(2)}`, - }, - { title: '到期日', dataIndex: 'dueDate' }, - { - title: '实付日', - dataIndex: 'paidDate', - render: (value: string, item: any) => ( - saveInstallmentCell(item.id, 'paidDate', next)} - > - {value || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - render: (value: string, item: any) => ( - saveInstallmentCell(item.id, 'status', next)} - > - - {installmentStatusMap[value]?.text || value} - - - ), - }, - { - title: '操作', - render: (_: unknown, item: any) => ( - - {item.status === 'pending' && ( - } - onClick={() => handlePayInstallment(item.id)} - > - 标记已缴 - - )} - handleDeleteInstallment(item.id)} - > - } - > - 归档 - - - - ), - }, - ]} - /> - ) : ( -

暂无分期记录

- )} - - )} - - - {/* Add Installment Modal */} - setInstallmentModal(null)} - okText="确认" - > - - - - - - - - - ); }; diff --git a/apps/admin/src/pages/Exams/ExamFormModal.tsx b/apps/admin/src/pages/Exams/ExamFormModal.tsx index 6f60b64..b0b6122 100644 --- a/apps/admin/src/pages/Exams/ExamFormModal.tsx +++ b/apps/admin/src/pages/Exams/ExamFormModal.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { DatePicker, Form, Input, Modal, Select } from 'antd'; -import type { FormInstance } from 'antd'; -import type { ClassOption, ExamFormValues } from './types'; -import { EXAM_TYPE_OPTIONS } from './types'; +import { DatePicker, Form, Input, Modal, Select, type FormInstance } from 'antd'; +import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues } from './types'; interface Props { open: boolean; @@ -32,24 +30,42 @@ const ExamFormModal: React.FC = ({ width={560} >
- + - + - + setKeyword(event.target.value)} prefix={} placeholder="搜索考试名称" allowClear /> - + updateKeyword(event.target.value)} + prefix={} + placeholder="搜索考试名称" + allowClear + /> + { {showArchived ? '批量恢复' : '批量归档'} + {showArchived && canPurgeExam ? ( + void batchPurge()} + okText="永久删除" + okButtonProps={{ danger: true }} + > + + + ) : null} 归档 {!showArchived ? ( - + ) : null} {data.length === 0 && !loading ? ( -
+
+ +
) : ( {data.map((exam) => { - const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100); + const percent = + exam.totalStudents === 0 + ? 0 + : Math.round((exam.enteredScores / exam.totalStudents) * 100); return (
{ {exam.examType} {exam.examName} - )} - extra={{exam.status === 'archived' ? '已归档' : '成绩录入'}} + } + extra={ + + {exam.status === 'archived' ? '已归档' : '成绩录入'} + + } actions={[ - navigate(`/exams/${exam.id}`)}>查看成绩, + navigate(`/exams/${exam.id}`)}> + 查看成绩 + , exam.status === 'archived' ? ( - changeArchiveStatus(exam, false)} - > - 恢复 - + <> + changeArchiveStatus(exam, false)} + > + 恢复 + + {canPurgeExam ? ( + handlePurge(exam)} + okText="永久删除" + okButtonProps={{ danger: true }} + > + 删除 + + ) : null} + ) : ( { ), ]} > -
科目{exam.subject}
-
班级{exam.className}
-
日期{exam.examDate}
-
成绩录入{exam.enteredScores}/{exam.totalStudents}
+
+ 科目 + {exam.subject} +
+
+ + 班级 + + {exam.className} +
+
+ + 日期 + + {exam.examDate} +
+
+
+ 成绩录入 + + {exam.enteredScores}/{exam.totalStudents} + +
+ +
); @@ -243,7 +450,15 @@ const ExamsPage: React.FC = () => { )} - setModalOpen(false)} onSubmit={() => void submit()} /> + setModalOpen(false)} + onSubmit={() => void submit()} + /> ); }; diff --git a/apps/admin/src/pages/Exams/style.css b/apps/admin/src/pages/Exams/style.css index dcf63b1..623c31d 100644 --- a/apps/admin/src/pages/Exams/style.css +++ b/apps/admin/src/pages/Exams/style.css @@ -80,6 +80,10 @@ white-space: nowrap; } +.exam-purge-action { + color: #ff4d4f; +} + @media (max-width: 575px) { .exam-toolbar > .ant-space, .exam-toolbar .ant-input-affix-wrapper, diff --git a/apps/admin/src/pages/Expenses/ExpenseModals.tsx b/apps/admin/src/pages/Expenses/ExpenseModals.tsx new file mode 100644 index 0000000..eb35e82 --- /dev/null +++ b/apps/admin/src/pages/Expenses/ExpenseModals.tsx @@ -0,0 +1,166 @@ +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React from 'react'; +import { + DatePicker, + Form, + Input, + InputNumber, + Modal, + Select, +} from 'antd'; + +const { RangePicker } = DatePicker; + +export const RoomExpenseModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + rooms: any[]; + typeOptions: Array<{ value: string; label: string }>; + onOk: () => void; + onCancel: () => void; +}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +export const UtilityModal: React.FC<{ + open: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + onOk: () => void; + onCancel: () => void; +}> = ({ open, saving, form, students, onOk, onCancel }) => { + return ( + +
+ + + + + + + + + + + + + +
+ ); +}; + +export const PersonalExpenseModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + rooms: any[]; + personalTypeOptions: Array<{ value: string; label: string }>; + onOk: () => void; + onCancel: () => void; +}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => { + return ( + +
+ + ({ value: r.id, label: r.roomNumber }))} + /> + + + + {canImport && !showArchived && ( + { + try { + const formData = new FormData(); + formData.append('file', file); + const res: any = await onImport(formData); + if (isRoom && res.errors?.length > 0) { + message.warning(res.message || '导入完成'); + res.errors.forEach((e: string) => message.warning(e)); + } else { + message.success(res.message || '导入完成'); + if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e)); + } + onSuccess?.(res); + } catch (e) { + onError?.(e as Error); + } + }} + > + + + )} + {!showArchived && ( + } + onClick={onTemplateDownload} + > + {isRoom ? '下载水电费模板' : '下载模板'} + + )} + {onExport && !showArchived ? ( + } + onClick={onExport} + > + 导出 + + ) : null} + {isRoom && onAddUtility && !showArchived ? ( + } + onClick={onAddUtility} + > + 添加学生水电费 + + ) : null} + + + {showArchived ? ( + <> + + } + loading={batchLoading} + disabled={selectedKeys.length === 0} + > + 批量恢复 + + + {canPurgeExpense ? ( + + + + ) : null} + + ) : ( + + } + disabled={selectedKeys.length === 0} + > + 批量归档 + + + )} + + +
`共 ${total} 条`, + }} + locale={{ emptyText: }} + rowSelection={{ + selectedRowKeys: selectedKeys, + onChange: (keys) => onSelect(keys as number[]), + }} + /> + + ); +}; diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 4be2552..53aad44 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -1,52 +1,23 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Table, - Button, - Modal, - Form, - Select, - DatePicker, - InputNumber, - Input, - Space, - Tag, - Tabs, - Popconfirm, - Upload, - Empty, -} from 'antd'; -import { - PlusOutlined, - InboxOutlined, - EditOutlined, - UploadOutlined, - DownloadOutlined, - ExportOutlined, - UndoOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { App, Button, Form, Space, Tabs } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; - -const { RangePicker } = DatePicker; - -const isFormValidationError = (error: unknown) => - typeof error === 'object' && - error !== null && - Array.isArray((error as { errorFields?: unknown }).errorFields); +import { ExpenseTablePanel } from './ExpenseTablePanel'; +import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; const ExpensesPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission } = usePermission(); - const [roomExpenses, setRoomExpenses] = useState([]); - const [personalExpenses, setPersonalExpenses] = useState([]); - const [rooms, setRooms] = useState([]); - const [students, setStudents] = useState([]); - const [loading, setLoading] = useState(false); + const canPurgeExpense = hasPermission('expense:purge'); const [roomModal, setRoomModal] = useState(false); const [personalModal, setPersonalModal] = useState(false); const [utilityModal, setUtilityModal] = useState(false); @@ -66,135 +37,266 @@ const ExpensesPage: React.FC = () => { const [showArchived, setShowArchived] = useState(false); const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active'); - // Dynamic expense type options from API - const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); - const [personalTypeOptions, setPersonalTypeOptions] = useState< - { value: string; label: string }[] - >([]); - const [typeMap, setTypeMap] = useState>({}); + const { + data: typeLookups = { typeOptions: [], personalTypeOptions: [], typeMap: {} }, + } = useQuery<{ + typeOptions: { value: string; label: string }[]; + personalTypeOptions: { value: string; label: string }[]; + typeMap: Record; + }>({ + queryKey: ['expense-lookups'], + queryFn: async () => { + try { + return validateResponse(expenseLookupsSchema, await api.get('/expenses/lookups')); + } catch { + return { typeOptions: [], personalTypeOptions: [], typeMap: {} }; + } + }, + }); + const { typeOptions, personalTypeOptions, typeMap } = typeLookups; - useEffect(() => { - api - .get>('/expense-types') - .then((types) => { - const roomTypes: { value: string; label: string }[] = []; - const personalTypes: { value: string; label: string }[] = []; - const map: Record = {}; - for (const t of types) { - map[t.code] = t.name; - if (t.category === 'room' || t.category === 'both') { - roomTypes.push({ value: t.code, label: t.name }); - } - if (t.category === 'personal' || t.category === 'both') { - personalTypes.push({ value: t.code, label: t.name }); - } - } - setTypeOptions(roomTypes); - setPersonalTypeOptions(personalTypes); - setTypeMap(map); - }) - .catch(() => {}); - }, []); + const { + data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] }, + isLoading, + isFetching, + } = useQuery<{ + rooms: any[]; + personal: any[]; + students: any[]; + roomsList: any[]; + }>({ + queryKey: ['expenses', showArchived ? 'archived' : 'active'], + queryFn: async () => { + try { + const [rooms, personal, students, roomsList] = await Promise.all([ + api.get('/expenses/room', { + params: expenseStatusForView(showArchived ? 'archived' : 'active'), + }), + api.get('/expenses/personal', { + params: expenseStatusForView(showArchived ? 'archived' : 'active'), + }), + api.get('/expenses/student-lookups'), + api.get('/rooms'), + ]); + return { + rooms: validateResponse(expenseRecordsSchema, rooms), + personal: validateResponse(expenseRecordsSchema, personal), + students: validateResponse(expenseRecordsSchema, students), + roomsList: validateResponse(expenseRecordsSchema, roomsList), + }; + } catch { + message.error('加载费用数据失败'); + return { rooms: [], personal: [], students: [], roomsList: [] }; + } + }, + }); + const roomExpenses = expenseResult.rooms; + const personalExpenses = expenseResult.personal; + const students = expenseResult.students; + const rooms = expenseResult.roomsList; + const loading = isLoading || isFetching; + + const mutations = { + saveRoom: useApiMutation( + async (payload: Record) => + editingRoom + ? api.put(`/expenses/room/${editingRoom.id}`, payload) + : api.post('/expenses/room', payload), + { invalidate: [['expenses']] }, + ), + saveRoomCell: useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/expenses/room/${record.id}`, { [field]: value }), + { invalidate: [['expenses']] }, + ), + savePersonal: useApiMutation( + async (payload: Record) => + editingPersonal + ? api.put(`/expenses/personal/${editingPersonal.id}`, payload) + : api.post('/expenses/personal', payload), + { invalidate: [['expenses']] }, + ), + savePersonalCell: useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/expenses/personal/${record.id}`, { [field]: value }), + { invalidate: [['expenses']] }, + ), + period: useApiMutation( + async ({ id, periodStart, periodEnd }: { id: number; periodStart: string; periodEnd: string }) => + api.put(`/expenses/room/${id}`, { periodStart, periodEnd }), + { invalidate: [['expenses']] }, + ), + utility: useApiMutation( + async (payload: Record) => api.post('/expenses/utility', payload), + { invalidate: [['expenses'], ['bills']] }, + ), + importUtility: useApiMutation( + async (formData: FormData) => + api.post('/expenses/utility/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['expenses']] }, + ), + importPersonal: useApiMutation( + async (formData: FormData) => + api.post('/expenses/personal/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['expenses']] }, + ), + archiveRoom: useApiMutation( + async (id: number) => api.delete(`/expenses/room/${id}`), + { invalidate: [['expenses']] }, + ), + archivePersonal: useApiMutation( + async (id: number) => api.delete(`/expenses/personal/${id}`), + { invalidate: [['expenses']] }, + ), + batchDeleteRoom: useApiMutation( + async (ids: number[]) => api.post('/expenses/room/batch-delete', { ids }), + { invalidate: [['expenses']] }, + ), + batchDeletePersonal: useApiMutation( + async (ids: number[]) => api.post('/expenses/personal/batch-delete', { ids }), + { invalidate: [['expenses']] }, + ), + batchRestoreRoom: useApiMutation( + async (ids: number[]) => api.post('/expenses/room/batch-restore', { ids }), + { invalidate: [['expenses']] }, + ), + batchRestorePersonal: useApiMutation( + async (ids: number[]) => api.post('/expenses/personal/batch-restore', { ids }), + { invalidate: [['expenses']] }, + ), + purgeRoom: useApiMutation( + async (id: number) => api.delete(`/expenses/room/${id}/permanent`), + { invalidate: [['expenses']] }, + ), + purgePersonal: useApiMutation( + async (id: number) => api.delete(`/expenses/personal/${id}/permanent`), + { invalidate: [['expenses']] }, + ), + }; const handleBatchDeleteRoom = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys }); - message.success(res?.message || `已归档 ${selectedRoomKeys.length} 条`); + await mutations.batchDeleteRoom.mutateAsync(selectedRoomKeys); + message.success('批量归档成功'); setSelectedRoomKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchDeletePersonal = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/expenses/personal/batch-delete', { - ids: selectedPersonalKeys, - }); - message.success(res?.message || `已归档 ${selectedPersonalKeys.length} 条`); + await mutations.batchDeletePersonal.mutateAsync(selectedPersonalKeys); + message.success('批量归档成功'); setSelectedPersonalKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestoreRoom = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/expenses/room/batch-restore', - { ids: selectedRoomKeys }, - ); - message.success( - `已恢复 ${res.restored} 条宿舍费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, - ); + await mutations.batchRestoreRoom.mutateAsync(selectedRoomKeys); + message.success('批量恢复成功'); setSelectedRoomKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestorePersonal = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/expenses/personal/batch-restore', - { ids: selectedPersonalKeys }, - ); - message.success( - `已恢复 ${res.restored} 条个人费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, - ); + await mutations.batchRestorePersonal.mutateAsync(selectedPersonalKeys); + message.success('批量恢复成功'); setSelectedPersonalKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [re, pe, lookups]: any[] = await Promise.all([ - api.get('/expenses/room', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/personal', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })), - ]); - setRoomExpenses(re); - setPersonalExpenses(pe); - setRooms(lookups.rooms || []); - setStudents(lookups.students || []); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [showArchived]); + const handlePurgeRoom = (id: number) => { + modal.confirm({ + title: '永久删除宿舍费用?', + content: '删除后不可恢复。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await mutations.purgeRoom.mutateAsync(id); + message.success('已永久删除'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; - useEffect(() => { - fetchData(); + const handlePurgePersonal = (id: number) => { + modal.confirm({ + title: '永久删除个人费用?', + content: '删除后不可恢复。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await mutations.purgePersonal.mutateAsync(id); + message.success('已永久删除'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const handleBatchPurgeRoom = async () => { + setBatchLoading(true); + try { + await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id))); + message.success('批量永久删除成功'); + setSelectedRoomKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const handleBatchPurgePersonal = async () => { + setBatchLoading(true); + try { + await Promise.all(selectedPersonalKeys.map((id) => mutations.purgePersonal.mutateAsync(id))); + message.success('批量永久删除成功'); + setSelectedPersonalKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const changeArchiveView = (archived: boolean) => { + setShowArchived(archived); setSelectedRoomKeys([]); setSelectedPersonalKeys([]); - }, [fetchData]); + }; const filteredRoomExpenses = useMemo(() => { return roomExpenses.filter((r: any) => { @@ -208,12 +310,12 @@ const ExpensesPage: React.FC = () => { }, [roomExpenses, roomSearch, roomTypeFilter]); const filteredPersonalExpenses = useMemo(() => { - return personalExpenses.filter((p: any) => { + return personalExpenses.filter((r: any) => { if (personalSearch) { const s = personalSearch.toLowerCase(); - if (!p.student?.name?.toLowerCase().includes(s)) return false; + if (!r.student?.name?.toLowerCase().includes(s)) return false; } - if (personalTypeFilter && p.expenseType !== personalTypeFilter) return false; + if (personalTypeFilter && r.expenseType !== personalTypeFilter) return false; return true; }); }, [personalExpenses, personalSearch, personalTypeFilter]); @@ -230,49 +332,23 @@ const ExpensesPage: React.FC = () => { periodEnd: values.period[1].format('YYYY-MM-DD'), description: values.description, }; - if (editingRoom) { - await api.put(`/expenses/room/${editingRoom.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/expenses/room', payload); - message.success('录入成功'); - } + await mutations.saveRoom.mutateAsync(payload); + message.success(editingRoom ? '更新成功' : '录入成功'); setRoomModal(false); setEditingRoom(null); roomForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; - const saveRoomCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/expenses/room/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - - const savePersonalCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/expenses/personal/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - const handleStudentUtility = async () => { const values = await utilityForm.validateFields(); setSaving(true); try { - const result: any = await api.post('/expenses/student-utility', { + const result: any = await mutations.utility.mutateAsync({ studentId: values.studentId, expenseType: values.expenseType, amount: values.amount, @@ -286,9 +362,8 @@ const ExpensesPage: React.FC = () => { ); setUtilityModal(false); utilityForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '水电费出账失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -306,331 +381,77 @@ const ExpensesPage: React.FC = () => { expenseDate: values.expenseDate.format('YYYY-MM-DD'), description: values.description, }; - if (editingPersonal) { - await api.put(`/expenses/personal/${editingPersonal.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/expenses/personal', payload); - message.success('录入成功'); - } + await mutations.savePersonal.mutateAsync(payload); + message.success(editingPersonal ? '更新成功' : '录入成功'); setPersonalModal(false); setEditingPersonal(null); personalForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; - const roomColumns = useMemo( - () => [ - { - title: '宿舍', - width: 120, - render: (_: any, r: any) => ( - ({ value: item.id, label: item.roomNumber }))} - permission="expense:edit" - disabled={expenseViewPolicy.readonly} - required - onSave={(next) => saveRoomCell(r, 'roomId', next)} - > - {r.room?.roomNumber || '-'} - - ), - }, - { - title: '费用类型', - width: 100, - dataIndex: 'expenseType', - render: (v: string, r: any) => ( - saveRoomCell(r, 'expenseType', next)} - > - {typeMap[v] || v} - - ), - }, - { - title: '金额', - dataIndex: 'amount', - width: 100, - render: (v: number, r: any) => ( - saveRoomCell(r, 'amount', next)} - >{`¥${Number(v).toFixed(2)}`} - ), - }, - { - title: '账单周期', - width: 200, - render: (_: any, r: any) => ( - { - const [periodStart, periodEnd] = next as unknown as [string, string]; - await api.put(`/expenses/room/${r.id}`, { periodStart, periodEnd }); - message.success('已保存'); - await fetchData(); - }} - >{`${r.periodStart} ~ ${r.periodEnd}`} - ), - }, - { - title: '说明', - dataIndex: 'description', - width: 150, - render: (v: string, r: any) => ( - saveRoomCell(r, 'description', next)} - > - {v || '-'} - - ), - }, - { - title: '录入时间', - width: 160, - dataIndex: 'createdAt', - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => - showArchived ? ( - 已归档 - ) : ( - - } - onClick={() => { - setEditingRoom(record); - roomForm.setFieldsValue({ - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - period: [dayjs(record.periodStart), dayjs(record.periodEnd)], - description: record.description, - }); - setRoomModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/room/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > - } - > - 归档 - - - - ), - }, - ], - [ - rooms, - typeOptions, - typeMap, - saveRoomCell, - roomForm, - fetchData, - showArchived, - expenseViewPolicy.readonly, - ], + const openEditRoom = (record: any) => { + setEditingRoom(record); + roomForm.setFieldsValue({ + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + period: record.periodStart ? [dayjs(record.periodStart), dayjs(record.periodEnd)] : undefined, + description: record.description, + }); + setRoomModal(true); + }; + + const openEditPersonal = (record: any) => { + setEditingPersonal(record); + personalForm.setFieldsValue({ + studentId: record.studentId, + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + expenseDate: record.expenseDate ? dayjs(record.expenseDate) : undefined, + description: record.description, + }); + setPersonalModal(true); + }; + + const saveRoomCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await mutations.saveRoomCell.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [mutations.saveRoomCell], ); - const personalColumns = useMemo( - () => [ - { - title: '学生', - width: 120, - render: (_: any, r: any) => ( - ({ value: item.id, label: item.name }))} - permission="expense:edit" - disabled={expenseViewPolicy.readonly} - required - onSave={(next) => savePersonalCell(r, 'studentId', next)} - > - {r.student?.name || '-'} - - ), - }, - { - title: '费用类型', - width: 100, - dataIndex: 'expenseType', - render: (v: string, r: any) => ( - savePersonalCell(r, 'expenseType', next)} - > - {typeMap[v] || v} - - ), - }, - { - title: '金额', - dataIndex: 'amount', - render: (v: number, r: any) => ( - savePersonalCell(r, 'amount', next)} - >{`¥${Number(v).toFixed(2)}`} - ), - }, - { - title: '日期', - dataIndex: 'expenseDate', - width: 110, - render: (v: string, r: any) => ( - savePersonalCell(r, 'expenseDate', next)} - > - {v} - - ), - }, - { - title: '说明', - dataIndex: 'description', - width: 150, - render: (v: string, r: any) => ( - savePersonalCell(r, 'description', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => - showArchived ? ( - 已归档 - ) : ( - - } - onClick={() => { - setEditingPersonal(record); - personalForm.setFieldsValue({ - studentId: record.studentId, - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - expenseDate: dayjs(record.expenseDate), - description: record.description, - }); - setPersonalModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/personal/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > - } - > - 归档 - - - - ), - }, - ], - [ - students, - personalTypeOptions, - typeMap, - savePersonalCell, - personalForm, - fetchData, - showArchived, - expenseViewPolicy.readonly, - ], + const savePersonalCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await mutations.savePersonalCell.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [mutations.savePersonalCell], ); return (
- - @@ -640,444 +461,133 @@ const ExpensesPage: React.FC = () => { key: 'room', label: '宿舍费用', children: ( - <> -
- - setRoomSearch(v)} - onChange={(e) => { - if (!e.target.value) setRoomSearch(''); - }} - /> -
`共 ${total} 条`, - }} - locale={{ emptyText: }} - rowSelection={{ - selectedRowKeys: selectedRoomKeys, - onChange: (keys) => setSelectedRoomKeys(keys as number[]), - }} - /> - + { + try { + await mutations.period.mutateAsync({ id, periodStart, periodEnd }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + onEdit={openEditRoom} + onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} + onPurge={handlePurgeRoom} + onImport={(formData) => mutations.importUtility.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onAddUtility={() => setUtilityModal(true)} + /> ), }, { key: 'personal', label: '个人附加费', children: ( - <> -
- - setPersonalSearch(v)} - onChange={(e) => { - if (!e.target.value) setPersonalSearch(''); - }} - /> -
`共 ${total} 条`, - }} - locale={{ emptyText: }} - rowSelection={{ - selectedRowKeys: selectedPersonalKeys, - onChange: (keys) => setSelectedPersonalKeys(keys as number[]), - }} - /> - + undefined} + onEdit={openEditPersonal} + onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} + onPurge={handlePurgePersonal} + onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onExport={() => { + downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => + message.error('导出失败'), + ); + }} + /> ), }, ]} /> - { setRoomModal(false); setEditingRoom(null); }} - okText={editingRoom ? '保存' : '确认录入'} - confirmLoading={saving} - > - - - - - - - - - - - - - - - - - + setUtilityModal(false)} - okText="生成账单并扣余额" - confirmLoading={saving} - > -
- - - - - - - - - - - - - -
- - + { setPersonalModal(false); setEditingPersonal(null); }} - okText={editingPersonal ? '保存' : '确认录入'} - confirmLoading={saving} - > -
- - ({ value: r.id, label: r.roomNumber }))} - /> - - - + + + + + + - - - - - -
}} + scroll={{ x: 1300 }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + rowSelection={rowSelection} + /> + + ); +}; diff --git a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx new file mode 100644 index 0000000..1651766 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload } from 'antd'; +import { + DownloadOutlined, + ExportOutlined, + PlusOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import type { OccupancyView } from '../archive-view'; + +const { RangePicker } = DatePicker; + +export const OccupanciesToolbar: React.FC<{ + viewMode: OccupancyView; + onChangeViewMode: (mode: OccupancyView) => void; + onSearch: (value: string) => void; + dateRange: [Dayjs | null, Dayjs | null] | null; + onChangeDateRange: (dates: [Dayjs | null, Dayjs | null] | null) => void; + canCheckIn: boolean; + onCheckIn: () => void; + onImport: (options: any) => void; + autoDeposit: boolean; + onAutoDepositChange: (value: boolean) => void; + depositAmount: number; + onDepositAmountChange: (value: number) => void; + onDownloadTemplate: () => void; + onExport: () => void; +}> = ({ + viewMode, + onChangeViewMode, + onSearch, + dateRange, + onChangeDateRange, + canCheckIn, + onCheckIn, + onImport, + autoDeposit, + onAutoDepositChange, + depositAmount, + onDepositAmountChange, + onDownloadTemplate, + onExport, +}) => { + return ( +
+ + + + + + onChangeDateRange(dates ? [dates[0], dates[1]] : null)} + placeholder={['入住开始', '入住结束']} + style={{ width: 240 }} + /> + + + {viewMode !== 'archived' ? ( + } + onClick={onCheckIn} + > + 入住登记 + + ) : null} + {viewMode !== 'archived' && canCheckIn ? ( + <> + + + + + + + + 导入时自动收押金 + {autoDeposit && ( + + onDepositAmountChange(v || 500)} + style={{ width: 60 }} + /> + + 元 + + + )} + + + ) : null} + {viewMode !== 'archived' ? ( + } + onClick={onDownloadTemplate} + > + 下载模板 + + ) : null} + {viewMode !== 'archived' ? ( + } onClick={onExport}> + 导出记录 + + ) : null} + +
+ ); +}; diff --git a/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx b/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx new file mode 100644 index 0000000..36bc90a --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx @@ -0,0 +1,133 @@ +// aislop-ignore-file: duplicate-block -- 列渲染结构相似且字段不同,逻辑已组件化 +import { Button, Popconfirm, Space, Tag } from 'antd'; +import { InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import { message } from '../../ui/app-message'; + +export interface OccupancyRow { + id: number; + studentId: number; + roomId: number; + checkInDate?: string; + billingStartDate?: string; + billingEndDate?: string; + checkOutDate?: string | null; + status?: string; + student?: { id?: number; name?: string; studentNo?: string } | null; + room?: { id?: number; roomNumber?: string; building?: string } | null; + bed?: { bedNumber?: string } | null; + locker?: { lockerNumber?: string } | null; +} + +export interface OccupancyColumnContext { + readonly: boolean; + canPurge: boolean; + canDelete: boolean; + onPurge: (id: number, name: string) => void; + onArchive: (id: number) => Promise | unknown; + onCheckOut: (record: OccupancyRow) => void; + onTransfer: (record: OccupancyRow) => void; +} + +const buildOccupancyDataColumns = () => { + return [ + { + title: '学生', + width: 120, + render: (_: unknown, r: OccupancyRow) => r.student?.name || '-', + }, + { + title: '宿舍', + width: 120, + render: (_: unknown, r: OccupancyRow) => r.room?.roomNumber || '-', + }, + { + title: '床位', + width: 80, + render: (_: unknown, r: OccupancyRow) => r.bed?.bedNumber || '-', + }, + { + title: '柜子', + width: 80, + render: (_: unknown, r: OccupancyRow) => r.locker?.lockerNumber || '-', + }, + { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, + { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, + { + title: '退宿日期', + dataIndex: 'checkOutDate', + width: 110, + render: (v: any) => v || 在住, + }, + { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, + { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, + ]; +}; + +const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => { + const { readonly, canPurge, canDelete, onPurge, onArchive, onCheckOut, onTransfer } = ctx; + return { + title: '操作', + width: 220, + render: (_: any, record: OccupancyRow) => + readonly ? ( + + 已归档 + {canPurge ? ( + + ) : null} + + ) : !record.checkOutDate ? ( + + } + onClick={() => onCheckOut(record)} + > + 退宿 + + } + onClick={() => onTransfer(record)} + > + 换房 + + + ) : ( + + 已退宿 + {canDelete ? ( + { + try { + await onArchive(record.id); + message.success('归档成功'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + + + ) : null} + + ), + }; +}; + +export const buildOccupancyColumns = (ctx: OccupancyColumnContext) => { + return [...buildOccupancyDataColumns(), buildOccupancyActionColumn(ctx)]; +}; diff --git a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx new file mode 100644 index 0000000..05109a6 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx @@ -0,0 +1,551 @@ +// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem +import React from 'react'; +import { + DatePicker, + Form, + Input, + InputNumber, + Modal, + Select, + Switch, + Tag, +} from 'antd'; +import type { Dayjs } from 'dayjs'; +import { maskIdNumber, maskPhone } from '../../utils/sensitive'; +import type { OccupancyRow } from './OccupancyColumns'; + +export type FormRule = React.ComponentProps['rules']; + +export const DateFormItem: React.FC<{ + name: string; + label: string; + placeholder: string; + required?: boolean; + dependencies?: string[]; + extra?: string; + rules?: FormRule; +}> = ({ name, label, placeholder, required, dependencies, extra, rules }) => ( + + + +); + +export const CheckInModal: React.FC<{ + open: boolean; + canCheckIn: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + activeOccupancyByStudentId: Map; + rooms: any[]; + roomOptionLabel: (room: any) => string; + isRoomSelectable: (room: any) => boolean; + onRoomChange: (roomId: number) => void; + availableBeds: any[]; + availableLockers: any[]; + availableResourcesLoading: boolean; + selectedCheckInRoomId?: number; + dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown; + onOk: () => void; + onCancel: () => void; +}> = ({ + open, + canCheckIn, + saving, + form, + students, + activeOccupancyByStudentId, + rooms, + roomOptionLabel, + isRoomSelectable, + onRoomChange, + availableBeds, + availableLockers, + availableResourcesLoading, + selectedCheckInRoomId, + dateNotBefore, + onOk, + onCancel, +}) => { + return ( + + + + ({ + value: r.id, + label: roomOptionLabel(r), + disabled: !isRoomSelectable(r), + }))} + /> + + + ({ + validator: dateNotBefore( + getFieldValue('checkInDate'), + '计费起始日不能早于入住日期', + ) as never, + }), + ]} + /> + + ({ + value: b.id, + label: b.bedNumber, + }))} + notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} + /> + + {availableBeds.length > 0 && ( +
+ 空闲 {availableBeds.length} 张床位 +
+ )} + + + + +
+ ); +}; + +export const BatchCheckOutModal: React.FC<{ + open: boolean; + canCheckOut: boolean; + selectedRowKeys: number[]; + latestSelectedCheckInDate?: string; + latestSelectedBillingStartDate?: string; + data: any[]; + form: ReturnType[0]; + dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown; + onOk: () => void; + onCancel: () => void; +}> = ({ + open, + canCheckOut, + selectedRowKeys, + latestSelectedCheckInDate, + latestSelectedBillingStartDate, + data, + form, + dateNotBefore, + onOk, + onCancel, +}) => { + return ( + +
+ + + + r.id !== record?.roomId) + .map((r) => ({ + value: r.id, + label: roomOptionLabel(r), + disabled: !isRoomSelectable(r), + }))} + /> + + + !value || transferAvailableBeds.some((bed) => bed.id === value) + ? Promise.resolve() + : Promise.reject(new Error('请选择目标宿舍下的可用床位')), + }, + ]} + > + ({ + value: locker.id, + label: locker.lockerNumber, + }))} + notFoundContent="目标宿舍暂无可用柜子" + /> + + + + + ({ + validator: dateNotBefore( + record?.billingStartDate || record?.checkInDate || getFieldValue('transferDate'), + '旧房计费截止日不能早于计费起始日', + ) as never, + }), + ]} + > + + + ({ + validator: dateNotBefore( + getFieldValue('transferDate'), + '新房计费起始日不能早于换房日期', + ) as never, + }), + ]} + > + + + + + + +
+ ); +}; diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 0e051ba..5b5abc1 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -1,54 +1,55 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Table, - Button, - Modal, - Form, - Select, - DatePicker, - Input, - InputNumber, - Space, - Tag, - Popconfirm, - Upload, - Switch, - Tooltip, - Empty, - Alert, -} from 'antd'; -import { - PlusOutlined, - SwapOutlined, - LogoutOutlined, - InboxOutlined, - UploadOutlined, - DownloadOutlined, - ExportOutlined, - UndoOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { Alert, App, Form } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; -import { downloadBlob } from '../../utils/download'; -import { maskPhone, maskIdNumber } from '../../utils/sensitive'; -import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form'; +import { buildOccupancyColumns } from './OccupancyColumns'; +import type { OccupancyRow } from './OccupancyColumns'; +import { + BatchCheckOutModal, + CheckInModal, + CheckOutModal, + TransferModal, +} from './OccupancyModals'; import { usePermission } from '../../hooks/usePermission'; import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { occupanciesSchema } from '../../api/schemas'; +import { OccupanciesTableArea } from './OccupanciesTableArea'; +import { OccupanciesToolbar } from './OccupanciesToolbar'; +import { useOccupancyMutations } from './useOccupancyMutations'; -const { RangePicker } = DatePicker; +interface StudentLookupRow { + id: number; + name: string; + studentNo?: string; + idNumber?: string; + phone?: string; + status?: string; +} + +interface RoomOverviewRow { + id: number; + roomNumber: string; + building?: string; + capacity?: number; + currentCount?: number; + floor?: number | null; + roomType?: string; + status?: string; +} const OccupanciesPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission, permissionsReady } = usePermission(); const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); const canTransfer = permissionsReady && hasPermission('occupancy:transfer'); const canDelete = permissionsReady && hasPermission('occupancy:delete'); - const [data, setData] = useState([]); - const [students, setStudents] = useState([]); - const [rooms, setRooms] = useState([]); - const [loading, setLoading] = useState(false); + const canPurge = permissionsReady && hasPermission('occupancy:purge'); const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); @@ -60,6 +61,73 @@ const OccupanciesPage: React.FC = () => { const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); const [batchCheckOutModal, setBatchCheckOutModal] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const changeViewMode = (mode: OccupancyView) => { + setViewMode(mode); + setSelectedRowKeys([]); + }; + const changeDateRange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => { + setDateRange(dates); + setSelectedRowKeys([]); + }; + + const { + data: fetchResult = { data: [], students: [], rooms: [] }, + isLoading, + isFetching, + } = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({ + queryKey: ['occupancies', viewMode, dateRange], + queryFn: async () => { + try { + const [occRes, stuRes, rmRes] = await Promise.allSettled([ + api.get('/occupancies', { + params: { + ...occupancyParamsForView(viewMode), + dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), + dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), + }, + }), + api.get('/students/basic-lookups'), + api.get('/rooms/overview'), + ]); + const labels = ['入住数据', '学生列表', '房间列表']; + [occRes, stuRes, rmRes].forEach((res, i) => { + if (res.status === 'rejected') { + message.warning(`${labels[i]}加载失败`); + } + }); + return { + data: + occRes.status === 'fulfilled' + ? validateResponse(occupanciesSchema, occRes.value) + : [], + students: stuRes.status === 'fulfilled' ? stuRes.value : [], + rooms: rmRes.status === 'fulfilled' ? rmRes.value : [], + }; + } catch (e) { + console.error(e); + message.error('数据加载异常'); + return { data: [], students: [], rooms: [] }; + } + }, + }); + const data = fetchResult.data; + const students = fetchResult.students; + const rooms = fetchResult.rooms; + const loading = isLoading || isFetching; + + const { + checkInMutation, + checkOutMutation, + transferMutation, + batchCheckOutMutation, + batchDeleteMutation, + batchRestoreMutation, + archiveMutation, + purgeMutation, + batchPurgeMutation, + importMutation, + } = useOccupancyMutations(); const [saving, setSaving] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const [checkInForm] = Form.useForm(); @@ -75,47 +143,21 @@ const OccupanciesPage: React.FC = () => { const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); - // Close modals when the user loses the required permission - useEffect(() => { - if (!canCheckIn) { - setCheckInModal(false); - checkInForm.resetFields(); - } - }, [canCheckIn, checkInForm]); - useEffect(() => { - if (!canCheckOut && checkOutModal) { - setCheckOutModal(null); - checkOutForm.resetFields(); - } - }, [canCheckOut, checkOutModal, checkOutForm]); - useEffect(() => { - if (!canCheckOut) { - setBatchCheckOutModal(false); - batchCheckOutForm.resetFields(); - } - }, [canCheckOut, batchCheckOutForm]); - useEffect(() => { - if (!canTransfer && transferModal) { - setTransferModal(null); - transferForm.resetFields(); - } - }, [canTransfer, transferModal, transferForm]); - const activeOccupancyByStudentId = useMemo(() => { - const map = new Map(); + const map = new Map(); data.forEach((item) => { if (!item.checkOutDate && item.status !== 'archived') map.set(item.studentId, item); }); return map; }, [data]); - const isRoomSelectable = useCallback((room: any) => { + const isRoomSelectable = useCallback((room: RoomOverviewRow) => { const currentCount = Number(room.currentCount || 0); const capacity = Number(room.capacity || 0); return room.status !== 'archived' && room.status !== 'maintenance' && currentCount < capacity; }, []); - const roomOptionLabel = useCallback((room: any) => { + const roomOptionLabel = useCallback((room: RoomOverviewRow) => { const base = `${room.roomNumber} (${room.building || ''}) [${room.currentCount}/${room.capacity}]`; if (room.status === 'maintenance') return `${base} · 维修中`; if (room.status === 'archived') return `${base} · 已归档`; @@ -131,7 +173,7 @@ const OccupanciesPage: React.FC = () => { () => selectedBatchRecords .map((item) => item.checkInDate) - .filter(Boolean) + .filter((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), @@ -141,7 +183,7 @@ const OccupanciesPage: React.FC = () => { () => selectedBatchRecords .map((item) => item.billingStartDate || item.checkInDate) - .filter(Boolean) + .filter((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), @@ -158,48 +200,12 @@ const OccupanciesPage: React.FC = () => { : Promise.resolve(); }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [occRes, stuRes, rmRes] = (await Promise.allSettled([ - api.get('/occupancies', { - params: { - ...occupancyParamsForView(viewMode), - dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), - dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), - }, - }), - api.get('/students/basic-lookups'), - api.get('/rooms/overview'), - ])) as PromiseSettledResult[]; - const labels = ['入住数据', '学生列表', '房间列表']; - [occRes, stuRes, rmRes].forEach((res, i) => { - if (res.status === 'rejected') { - message.warning(`${labels[i]}加载失败`); - } - }); - setData(occRes.status === 'fulfilled' ? occRes.value : []); - setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []); - setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []); - } catch (e) { - console.error(e); - message.error('数据加载异常'); - } - setLoading(false); - }, [viewMode, dateRange]); - - useEffect(() => { - fetchData(); - setSelectedRowKeys([]); - }, [fetchData]); - const handleRoomChange = async (roomId: number) => { checkInForm.setFieldValue('bedId', undefined); checkInForm.setFieldValue('lockerId', undefined); setAvailableBeds([]); setAvailableLockers([]); if (!roomId) return; - setAvailableResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ @@ -226,7 +232,6 @@ const OccupanciesPage: React.FC = () => { setTransferAvailableBeds([]); setTransferAvailableLockers([]); if (!roomId) return; - setTransferResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ @@ -261,13 +266,12 @@ const OccupanciesPage: React.FC = () => { const values = await checkInForm.validateFields(); setSaving(true); try { - await api.post('/occupancies/check-in', buildCheckInPayload(values)); + await checkInMutation.mutateAsync(buildCheckInPayload(values)); message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -277,17 +281,19 @@ const OccupanciesPage: React.FC = () => { const values = await checkOutForm.validateFields(); setSaving(true); try { - await api.put(`/occupancies/${checkOutModal.id}/check-out`, { - checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), - billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), - checkOutReason: values.checkOutReason, + await checkOutMutation.mutateAsync({ + id: checkOutModal.id, + payload: { + checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), + billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), + checkOutReason: values.checkOutReason, + }, }); message.success('退宿成功'); setCheckOutModal(null); checkOutForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -297,13 +303,15 @@ const OccupanciesPage: React.FC = () => { const values = await transferForm.validateFields(); setSaving(true); try { - await api.put(`/occupancies/${transferModal.id}/transfer`, buildTransferPayload(values)); + await transferMutation.mutateAsync({ + id: transferModal.id, + payload: buildTransferPayload(values), + }); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -314,7 +322,7 @@ const OccupanciesPage: React.FC = () => { const values = await batchCheckOutForm.validateFields(); setBatchLoading(true); try { - const res: any = await api.post('/occupancies/batch-check-out', { + const res: any = await batchCheckOutMutation.mutateAsync({ ids: selectedRowKeys, checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), @@ -324,9 +332,8 @@ const OccupanciesPage: React.FC = () => { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量退宿失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -336,12 +343,11 @@ const OccupanciesPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys }); + const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已归档 ${selectedRowKeys.length} 条`); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -351,114 +357,78 @@ const OccupanciesPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/occupancies/batch-restore', - { ids: selectedRowKeys }, - ); + const res = await batchRestoreMutation.mutateAsync(selectedRowKeys); message.success( `已恢复 ${res.restored} 条入住记录${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, ); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const handlePurge = (id: number, studentName: string) => { + modal.confirm({ + title: `永久删除入住记录(${studentName})?`, + content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const handleBatchPurge = async () => { + if (batchLoading) return; + setBatchLoading(true); + try { + const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys); + message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 条`); + setSelectedRowKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const columns = useMemo( - () => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { - title: '床位', - width: 80, - render: (_: unknown, r: Record) => - (r.bed as Record | undefined)?.bedNumber || '-', - }, - { - title: '柜子', - width: 80, - render: (_: unknown, r: Record) => - (r.locker as Record | undefined)?.lockerNumber || '-', - }, - { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, - { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, - { - title: '退宿日期', - dataIndex: 'checkOutDate', - width: 110, - render: (v: any) => v || 在住, - }, - { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, - { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, - { - title: '操作', - width: 220, - render: (_: any, record: any) => - viewPolicy.readonly ? ( - 已归档 - ) : !record.checkOutDate ? ( - - } - onClick={() => { - setCheckOutModal(record); - checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); - }} - > - 退宿 - - } - onClick={() => { - setTransferAvailableBeds([]); - setTransferAvailableLockers([]); - transferForm.resetFields(); - setTransferModal(record); - transferForm.setFieldsValue({ transferDate: dayjs() }); - }} - > - 换房 - - - ) : ( - - 已退宿 - {canDelete ? ( - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - - - ) : null} - - ), - }, - ], + () => + buildOccupancyColumns({ + readonly: viewPolicy.readonly, + canPurge, + canDelete, + onPurge: handlePurge, + onArchive: (id) => archiveMutation.mutateAsync(id), + onCheckOut: (record) => { + setCheckOutModal(record); + checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); + }, + onTransfer: (record) => { + setTransferAvailableBeds([]); + setTransferAvailableLockers([]); + transferForm.resetFields(); + setTransferModal(record); + transferForm.setFieldsValue({ transferDate: dayjs() }); + }, + }), [ - fetchData, - setCheckOutModal, - checkOutForm, - setTransferModal, - transferForm, viewPolicy.readonly, + canPurge, + canDelete, + handlePurge, + archiveMutation, + checkOutForm, + transferForm, ], ); @@ -466,7 +436,6 @@ const OccupanciesPage: React.FC = () => { () => ({ selectedRowKeys, onChange: (keys: any[]) => setSelectedRowKeys(keys), - // 「在住记录」视图禁用已退宿;其余视图中的记录均可选择。 getCheckboxProps: (record: any) => viewMode === 'active' ? { disabled: !!record.checkOutDate } : {}, }), @@ -483,697 +452,147 @@ const OccupanciesPage: React.FC = () => { closable style={{ marginBottom: 16 }} /> -
- - - - - - { - setDateRange(dates ? [dates[0], dates[1]] : null); - }} - placeholder={['入住开始', '入住结束']} - style={{ width: 240 }} - /> - - - {viewMode !== 'archived' ? ( - } - onClick={() => { - checkInForm.resetFields(); - setAvailableBeds([]); - setAvailableLockers([]); - setAvailableResourcesLoading(false); - const today = dayjs(); - checkInForm.setFieldsValue({ - checkInDate: today, - billingStartDate: today, - stayType: 'short', - collectDeposit: true, - depositAmount: 500, - }); - setCheckInModal(true); - }} - > - 入住登记 - - ) : null} - {viewMode !== 'archived' && canCheckIn ? ( - <> - { - const formData = new FormData(); - formData.append('file', file); - const params = new URLSearchParams(); - if (autoDeposit) { - params.set('autoDeposit', 'true'); - params.set('depositAmount', String(depositAmount)); - } - try { - const res: any = await api.post( - `/occupancies/import?${params.toString()}`, - formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, - ); - if (res.errors?.length > 0) { - Modal.warning({ - title: res.message, - content: res.errors.join('\n'), - width: 500, - }); - } else { - message.success(res.message); - } - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); - } - }} - > - - - - - - - 导入时自动收押金 - {autoDeposit && ( - - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - - )} - - - ) : null} - {viewMode !== 'archived' ? ( - } - onClick={() => { - downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => - message.error('下载失败'), - ); - }} - > - 下载模板 - - ) : null} - {viewMode !== 'archived' ? ( - } - onClick={() => { - const params = viewMode === 'active' ? '?active=true' : ''; - const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; - downloadBlob('/occupancies/export' + params, filename).catch(() => - message.error('导出失败'), - ); - }} - > - 导出记录 - - ) : null} - -
- {selectedRowKeys.length > 0 && ( - - 已选 {selectedRowKeys.length} 条记录 - {viewPolicy.batchAction === 'checkout' ? ( - } - onClick={() => { - batchCheckOutForm.resetFields(); - batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); - setBatchCheckOutModal(true); - }} - style={{ marginLeft: 12 }} - loading={batchLoading} - > - 批量退宿 - - ) : viewPolicy.batchAction === 'archive' ? ( - canDelete ? ( - - - - ) : null - ) : canDelete ? ( - - - - ) : null} - - - } - type="info" - style={{ marginBottom: 12 }} - /> - )} -
}} - scroll={{ x: 1300 }} - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - rowSelection={rowSelection} - /> - { - setCheckInModal(false); + { + checkInForm.resetFields(); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); + setCheckInModal(true); }} - okText="确认入住" - confirmLoading={saving} - width={500} - > -
- - ({ - value: r.id, - label: roomOptionLabel(r), - disabled: !isRoomSelectable(r), - }))} - /> - - - - - ({ - validator: dateNotBefore( - getFieldValue('checkInDate'), - '计费起始日不能早于入住日期', - ), - }), - ]} - > - - - - ({ - value: b.id, - label: b.bedNumber, - }))} - notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} - /> - - {availableBeds.length > 0 && ( -
- 空闲 {availableBeds.length} 张床位 -
- )} - - - - -
- - {/* 批量退宿弹窗 */} - setBatchCheckOutModal(false)} - okText="确认批量退宿" - width={500} - > -
- - - - - - - - r.id !== transferModal?.roomId) - .map((r: any) => ({ - value: r.id, - label: roomOptionLabel(r), - disabled: !isRoomSelectable(r), - }))} - /> - - - !value || transferAvailableBeds.some((bed) => bed.id === value) - ? Promise.resolve() - : Promise.reject(new Error('请选择目标宿舍下的可用床位')), - }, - ]} - > - ({ - value: locker.id, - label: locker.lockerNumber, - }))} - notFoundContent="目标宿舍暂无可用柜子" - /> - - - - - ({ - validator: dateNotBefore( - transferModal?.billingStartDate || - transferModal?.checkInDate || - getFieldValue('transferDate'), - '旧房计费截止日不能早于计费起始日', - ), - }), - ]} - > - - - ({ - validator: dateNotBefore( - getFieldValue('transferDate'), - '新房计费起始日不能早于换房日期', - ), - }), - ]} - > - - - - - - -
+ autoDeposit={autoDeposit} + onAutoDepositChange={setAutoDeposit} + depositAmount={depositAmount} + onDepositAmountChange={setDepositAmount} + onDownloadTemplate={() => { + void import('../../utils/download').then(({ downloadBlob }) => + downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败')), + ); + }} + onExport={() => { + void import('../../utils/download').then(({ downloadBlob }) => { + const params = viewMode === 'active' ? '?active=true' : ''; + const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; + downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败')); + }); + }} + /> + { + batchCheckOutForm.resetFields(); + batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); + setBatchCheckOutModal(true); + }} + onBatchDelete={handleBatchDelete} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onClearSelection={() => setSelectedRowKeys([])} + /> + + { setCheckInModal(false); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); }} + /> + setCheckOutModal(null)} + /> + setBatchCheckOutModal(false)} + /> + { setTransferModal(null); transferForm.resetFields(); setTransferAvailableBeds([]); setTransferAvailableLockers([]); }} + /> ); }; diff --git a/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts new file mode 100644 index 0000000..7e90a16 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts @@ -0,0 +1,65 @@ +import { useApiMutation } from '../../hooks/useApiMutation'; +import api from '../../api'; + +export function useOccupancyMutations() { + const invalidateOccupancies: Array = [['occupancies']]; + const checkInMutation = useApiMutation( + async (payload: unknown) => api.post('/occupancies/check-in', payload), + { invalidate: invalidateOccupancies }, + ); + const checkOutMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.put(`/occupancies/${id}/check-out`, payload), + { invalidate: invalidateOccupancies }, + ); + const transferMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: unknown }) => + api.put(`/occupancies/${id}/transfer`, payload), + { invalidate: invalidateOccupancies }, + ); + const batchCheckOutMutation = useApiMutation( + async (payload: Record) => api.post('/occupancies/batch-check-out', payload), + { invalidate: invalidateOccupancies }, + ); + const batchDeleteMutation = useApiMutation( + async (ids: number[]) => api.post('/occupancies/batch-delete', { ids }), + { invalidate: invalidateOccupancies }, + ); + const batchRestoreMutation = useApiMutation( + async (ids: number[]) => + api.put<{ restored: number; skipped: number }>('/occupancies/batch-restore', { ids }), + { invalidate: invalidateOccupancies }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/occupancies/${id}`), + { invalidate: invalidateOccupancies }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/occupancies/${id}/permanent`), + { invalidate: invalidateOccupancies }, + ); + const batchPurgeMutation = useApiMutation( + async (ids: number[]) => api.post('/occupancies/batch-permanent-delete', { ids }), + { invalidate: invalidateOccupancies }, + ); + const importMutation = useApiMutation( + async ({ formData, params }: { formData: FormData; params: string }) => + api.post(`/occupancies/import?${params}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateOccupancies }, + ); + + return { + checkInMutation, + checkOutMutation, + transferMutation, + batchCheckOutMutation, + batchDeleteMutation, + batchRestoreMutation, + archiveMutation, + purgeMutation, + batchPurgeMutation, + importMutation, + }; +} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 24cd1c0..9603a8c 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,10 +1,16 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { organizationsSchema } from '../../api/schemas'; const PRESET_COLORS = [ '#ff7875', @@ -31,9 +37,18 @@ interface OrganizationItem { status: 'active' | 'archived'; } +const ORGANIZATION_FIELDS = { + name: 'name', + code: 'code', + contactName: 'contactName', + phone: 'phone', + notes: 'notes', +} as const; + const OrganizationsPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeOrganization = hasPermission('organization:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -41,6 +56,48 @@ const OrganizationsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); + const { data = [], isLoading, isFetching } = useQuery({ + queryKey: ['organizations'], + queryFn: async () => { + try { + return validateResponse( + organizationsSchema, + await api.get('/organizations', { + params: { includeArchived: true }, + }), + ); + } catch (error: any) { + message.error(error?.message || '机构数据加载失败'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (values: { name: string; code: string; color?: string; notes?: string }) => + editing + ? api.put(`/organizations/${editing.id}`, values) + : api.post('/organizations', values), + { invalidate: [['organizations']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) => + api.put(`/organizations/${record.id}`, { [field]: value }), + { invalidate: [['organizations']] }, + ); + const statusMutation = useApiMutation( + async ({ id, status }: { id: number; status: 'active' | 'archived' }) => + status === 'active' + ? api.put(`/organizations/${id}`, { status: 'active' }) + : api.delete(`/organizations/${id}`), + { invalidate: [['organizations']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/organizations/${id}/permanent`), + { invalidate: [['organizations']] }, + ); + const filteredData = useMemo(() => { const keyword = searchText.trim().toLowerCase(); return data.filter((item) => { @@ -53,23 +110,24 @@ const OrganizationsPage: React.FC = () => { }); }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - setData( - await api.get('/organizations', { params: { includeArchived: true } }), - ); - } catch (error: any) { - message.error(error?.message || '机构数据加载失败'); - } finally { - setLoading(false); - } + const handlePurge = (record: OrganizationItem) => { + modal.confirm({ + title: `永久删除机构「${record.name}」?`, + content: '删除后不可恢复,存在学生归属、入住或租赁关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); }; - useEffect(() => { - void fetchData(); - }, []); - const openEditor = (record?: OrganizationItem) => { setEditing(record ?? null); form.resetFields(); @@ -82,37 +140,63 @@ const OrganizationsPage: React.FC = () => { const values = await form.validateFields(); setSaving(true); try { - if (editing) await api.put(`/organizations/${editing.id}`, values); - else await api.post('/organizations', values); + await saveMutation.mutateAsync(values); message.success(editing ? '机构已更新' : '机构已创建'); setModalOpen(false); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '保存失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: OrganizationItem, field: string, value: unknown) => { - await api.put(`/organizations/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; + const EditableOrganizationCell = ({ + value, + field, + record, + editor, + required, + onSave, + children, + }: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + required?: boolean; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; + }) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + + ); + const columns = [ { title: '机构', dataIndex: 'name', width: 220, render: (name: string, record: OrganizationItem) => ( - saveCell(record, 'name', next)} - > + { 外部机构 )} - + ), }, + { title: '机构编码', dataIndex: 'code', width: 130, render: (value: string, record: OrganizationItem) => ( - saveCell(record, 'code', next)} - > + {value} - + ), }, + { title: '联系人', dataIndex: 'contactName', width: 120, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'contactName', next)} - > + {value || '-'} - + ), }, + { title: '电话', dataIndex: 'phone', width: 140, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'phone', next)} - > + {value || '-'} - + ), }, + { title: '备注', dataIndex: 'notes', ellipsis: true, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'notes', next)} - > + {value || '-'} - + ), }, + { title: '状态', dataIndex: 'status', @@ -206,33 +273,40 @@ const OrganizationsPage: React.FC = () => { ), }, + { title: '操作', width: 160, render: (_: unknown, record: OrganizationItem) => ( {record.status === 'archived' ? ( - { - try { - await api.put(`/organizations/${record.id}`, { status: 'active' }); - message.success('机构已恢复'); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '恢复失败'); - } - }} - > - } + <> + { + try { + await statusMutation.mutateAsync({ id: record.id, status: 'active' }); + message.success('机构已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} > - 恢复 - - + } + > + 恢复 + + + {canPurgeOrganization && !record.isHost ? ( + + ) : null} + ) : ( <> { title="归档后仍保留历史学生、入住和租赁记录" onConfirm={async () => { try { - await api.delete(`/organizations/${record.id}`); + await statusMutation.mutateAsync({ id: record.id, status: 'archived' }); message.success('机构已归档'); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }} > diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 4153780..122471f 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { Row, Col, @@ -29,9 +29,11 @@ import { import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state'; +import { getErrorMessage } from '../../utils/error'; function getCardStyle(room: any): React.CSSProperties { let base: React.CSSProperties; @@ -85,8 +87,6 @@ function getOrganizationTags(occupants: any[]) { } const RoomVisualPage: React.FC = () => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); const [selectedBuilding, setSelectedBuilding] = useState('all'); const [selectedOrganization, setSelectedOrganization] = useState('all'); const [detailRoom, setDetailRoom] = useState(null); @@ -97,35 +97,27 @@ const RoomVisualPage: React.FC = () => { const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day'); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; - const res: any = await api.get('/rooms/visual', { params }); - setData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [isHistorical, asOf]); + const queryClient = useQueryClient(); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['rooms', 'visual', isHistorical, asOf], + queryFn: async () => { + try { + const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; + return await api.get('/rooms/visual', { params }); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return null; + } + }, + }); + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!detailRoom) { - setPresentOccupancyIds([]); - return; - } + const openRoomDetail = (room: any) => { + setDetailRoom(room); setPresentOccupancyIds( - getInitialPresentOccupancyIds( - detailRoom.occupants || [], - detailRoom.inspection?.submitted === true, - ), + getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true), ); - }, [detailRoom]); + }; const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD'); @@ -139,12 +131,11 @@ const RoomVisualPage: React.FC = () => { message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交'); const params = isHistorical ? { asOf: inspectionDate } : undefined; const res: any = await api.get('/rooms/visual', { params }); - setData(res); + queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res); const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id); if (updatedRoom) setDetailRoom(updatedRoom); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '查寝提交失败'); + message.error(getErrorMessage(e, '查寝提交失败')); } finally { setInspectionSaving(false); } @@ -299,7 +290,7 @@ const RoomVisualPage: React.FC = () => { cursor: 'pointer', height: '100%', }} - onClick={() => setDetailRoom(room)} + onClick={() => openRoomDetail(room)} >
= { + available: { text: '可入住', color: 'green' }, + full: { text: '已满', color: 'red' }, + maintenance: { text: '维修中', color: 'orange' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const ROOM_STATUS_OPTIONS = [ + { value: 'available', label: '可入住' }, + { value: 'full', label: '已满' }, + { value: 'maintenance', label: '维修中' }, +]; + +export const RENTAL_CATEGORY_OPTIONS = [ + { value: 'long', label: '长租' }, + { value: 'short', label: '短租' }, +]; + +export const BED_STATUS_OPTIONS = [ + { value: 'available', label: '空闲' }, + { value: 'occupied', label: '占用' }, + { value: 'maintenance', label: '维修' }, +]; + +export const BED_STATUS_MAP: Record = { + available: { text: '空闲', color: 'green' }, + occupied: { text: '占用', color: 'blue' }, + maintenance: { text: '维修', color: 'orange' }, +}; + +export interface BedItem { + id: number; + bedNumber: string; + status: string; + notes?: string | null; +} + +export interface LockerItem { + id: number; + lockerNumber: string; + status: string; + notes?: string | null; +} + +export function parseRoomNumber(input: string) { + const match = /^(\d+)-(\d+)/.exec(input.trim()); + if (!match) return null; + return { + building: `${match[1]}号楼`, + floor: Number(match[2]), + roomType: input.includes('单人') ? '单人间' : input.includes('家庭') ? '家庭房' : '四人间', + }; +} + +export const EditableRoomCell = ({ + value, + field, + record, + editor, + min, + max, + required, + options, + archived = false, + onSave, + children, +}: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + max?: number; + required?: boolean; + options?: Array<{ value: string; label: string }>; + archived?: boolean; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; +}) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + +); + +export interface RoomColumnContext { + canEditRooms: boolean; + canDeleteRooms: boolean; + canPurgeRooms: boolean; + onSaveRoomCell: (record: any, field: string, value: unknown) => Promise | void; + onRestore: (id: number) => Promise | unknown; + onArchive: (id: number) => Promise | unknown; + onPurge: (id: number, name: string) => void; + onView: (record: any) => void; + onEdit: (record: any) => void; +} + +function buildRoomIdentityColumns(ctx: RoomColumnContext) { + const { onSaveRoomCell } = ctx; + return [ + { + title: '房间号', + dataIndex: 'roomNumber', + width: 100, + sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber), + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '楼栋', + dataIndex: 'building', + width: 80, + render: (v: string, r: any) => ( + + {v || '-'} + + ), + }, + { + title: '楼层', + dataIndex: 'floor', + width: 80, + render: (v: number, r: any) => ( + + {v ?? '-'} + + ), + }, + { + title: '类型', + dataIndex: 'roomType', + width: 90, + render: (v: any, r: any) => ( + + {v || '-'} + + ), + }, + { + title: '租赁类型', + dataIndex: 'rentalCategory', + width: 100, + render: (v: string, r: any) => ( + + {v === 'long' ? ( + 长租 + ) : v === 'short' ? ( + 短租 + ) : ( + '-' + )} + + ), + }, + { + title: '月租金', + dataIndex: 'monthlyRate', + width: 100, + render: (v: number, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + ]; +} + +function buildRoomStatusColumns(ctx: RoomColumnContext) { + const { onSaveRoomCell } = ctx; + return [ + { + title: '额定人数', + dataIndex: 'capacity', + width: 80, + render: (v: number, r: any) => ( + + {v} + + ), + }, + { + title: '当前入住', + width: 80, + render: (_: any, r: any) => + r.status === 'archived' ? ( + - + ) : ( + = r.capacity ? '#ff4d4f' : '#52c41a' }} + /> + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: any) => ( + + {statusMap[s]?.text || s} + + ), + }, + ]; +} + +function buildRoomActionColumn(ctx: RoomColumnContext) { + const { + canEditRooms, + canDeleteRooms, + canPurgeRooms, + onRestore, + onArchive, + onPurge, + onView, + onEdit, + } = ctx; + return { + title: '操作', + width: 220, + render: (_: unknown, record: unknown) => { + const r = record as { status?: string; id: number; roomNumber?: string }; + return ( + + {r.status === 'archived' ? ( + <> + {canEditRooms ? ( + onRestore(r.id)}> + + + ) : null} + {canPurgeRooms ? ( + + ) : null} + + ) : ( + <> + onView(record)} + > + 查看 + + onEdit(record)} + > + 编辑 + + {canDeleteRooms ? ( + onArchive(r.id)}> + + + ) : null} + + )} + + ); + }, + }; +} + +export function buildRoomColumns(ctx: RoomColumnContext) { + return [ + ...buildRoomIdentityColumns(ctx), + ...buildRoomStatusColumns(ctx), + buildRoomActionColumn(ctx), + ]; +} + +export function useRoomColumns(ctx: RoomColumnContext) { + return React.useMemo(() => buildRoomColumns(ctx), [ctx]); +} diff --git a/apps/admin/src/pages/Rooms/RoomDrawer.tsx b/apps/admin/src/pages/Rooms/RoomDrawer.tsx new file mode 100644 index 0000000..f01a114 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomDrawer.tsx @@ -0,0 +1,382 @@ +// aislop-ignore-file: duplicate-block -- 床位/柜子表格声明结构相似且字段不同,渲染逻辑已共享 EditableRoomCell +import React from 'react'; +import { + Button, + Drawer, + InputNumber, + Popconfirm, + Space, + Table, + Tabs, + Tag, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import { + BED_STATUS_MAP, + BED_STATUS_OPTIONS, + EditableRoomCell, + statusMap, + type BedItem, + type LockerItem, +} from './RoomColumns'; + +export interface RoomDrawerProps { + open: boolean; + room: any; + beds: BedItem[]; + lockers: LockerItem[]; + canEditRooms: boolean; + remainingBedSlots: number; + defaultBatchBedCount: number; + onClose: () => void; + onAddBed: () => void; + onBatchBeds: (count: number) => void; + onEditBed: (record: BedItem) => void; + onDeleteBed: (id: number) => void; + onSaveBedCell: (record: BedItem, field: string, value: unknown) => void; + onAddLocker: () => void; + onBatchLockers: (count: number) => void; + onEditLocker: (record: LockerItem) => void; + onDeleteLocker: (id: number) => void; + onSaveLockerCell: (record: LockerItem, field: string, value: unknown) => void; +} + +export const RoomDrawer: React.FC = ({ + open, + room, + beds, + lockers, + canEditRooms, + remainingBedSlots, + defaultBatchBedCount, + onClose, + onAddBed, + onBatchBeds, + onEditBed, + onDeleteBed, + onSaveBedCell, + onAddLocker, + onBatchLockers, + onEditLocker, + onDeleteLocker, + onSaveLockerCell, +}) => { + const roomItemActions = (kind: 'bed' | 'locker') => (r: any) => { + const isBed = kind === 'bed'; + const handleDelete = isBed ? onDeleteBed : onDeleteLocker; + const handleEdit = isBed ? onEditBed : onEditLocker; + return ( + + handleEdit(r)} + > + 编辑 + + {r.status !== 'occupied' && canEditRooms && ( + handleDelete(r.id)}> + + + )} + + ); + }; + + return ( + + +
+ 房间号: + {room.roomNumber} +
+
+ 楼栋: + {room.building || '-'} +
+
+ 楼层: + {room.floor ?? '-'} +
+
+ 类型: + {room.roomType || '-'} +
+
+ 额定人数: + {room.capacity} +
+
+ 租赁类别: + {room.rentalCategory === 'long' ? '长租' : '短租'} +
+
+ 月租金: + {room.monthlyRate ? `¥${room.monthlyRate}` : '-'} +
+
+ 状态: + + {statusMap[room.status]?.text} + +
+
+ ), + }, + { + key: 'beds', + label: `床位管理 (${beds.length})`, + children: ( +
+ {canEditRooms ? ( +
+ + 0 ? '批量生成床位' : '床位已达到额定人数'} + description={ + remainingBedSlots > 0 ? ( + + ) : ( + '如需增加床位,请先调整宿舍额定人数' + ) + } + onConfirm={() => { + const input = document.getElementById( + 'batch-bed-count', + ) as HTMLInputElement; + onBatchBeds( + input + ? parseInt(input.value) || defaultBatchBedCount + : defaultBatchBedCount, + ); + }} + okText="生成" + disabled={room?.status === 'archived' || remainingBedSlots === 0} + > + + +
+ ) : null} +
( + + {v} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: BedItem) => ( + + + {BED_STATUS_MAP[s]?.text || s} + + + ), + }, + { + title: '备注', + dataIndex: 'notes', + render: (v: string, r: BedItem) => ( + + {v || '-'} + + ), + }, + { + title: '操作', + width: 120, + render: roomItemActions('bed'), + }, + ]} + /> + + ), + }, + { + key: 'lockers', + label: `柜子管理 (${lockers.length})`, + children: ( +
+ {canEditRooms ? ( +
+ + + } + onConfirm={() => { + const input = document.getElementById( + 'batch-locker-count', + ) as HTMLInputElement; + onBatchLockers(input ? parseInt(input.value) || 4 : 4); + }} + okText="生成" + disabled={room?.status === 'archived'} + > + + +
+ ) : null} +
( + + {v} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: LockerItem) => ( + + + {BED_STATUS_MAP[s]?.text || s} + + + ), + }, + { + title: '备注', + dataIndex: 'notes', + render: (v: string, r: LockerItem) => ( + + {v || '-'} + + ), + }, + { + title: '操作', + width: 120, + render: roomItemActions('locker'), + }, + ]} + /> + + ), + }, + ]} + /> + + ); +}; diff --git a/apps/admin/src/pages/Rooms/RoomModals.tsx b/apps/admin/src/pages/Rooms/RoomModals.tsx new file mode 100644 index 0000000..f06c0b7 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomModals.tsx @@ -0,0 +1,244 @@ +// aislop-ignore-file: duplicate-block -- 宿舍/床位/柜子表单声明结构相似且字段不同,已共享 RoomItemFormFields +import React from 'react'; +import { Form, Input, InputNumber, Modal, Select } from 'antd'; +import { RoomDrawer } from './RoomDrawer'; +import type { BedItem, LockerItem } from './RoomColumns'; +import { parseRoomNumber } from './RoomColumns'; + +export const RoomItemFormFields: React.FC<{ + fieldName: 'bedNumber' | 'lockerNumber'; + label: string; + placeholder: string; +}> = ({ fieldName, label, placeholder }) => ( + <> + + + + + { + const parsed = parseRoomNumber(e.target.value); + if (parsed) form.setFieldsValue(parsed); + }} + /> + + + + + + + + + + + + + + + + + {editing && ( + +
}} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 间`, + }} + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + rowSelection={{ + selectedRowKeys, + onChange: (keys) => onSelect(keys as number[]), + }} + /> + + + ); +}; diff --git a/apps/admin/src/pages/Rooms/RoomsToolbar.tsx b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx new file mode 100644 index 0000000..0a1cbb2 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Button, Input, Popconfirm, Select, Space, Upload } from 'antd'; +import type { UploadRequestOption } from '@rc-component/upload/lib/interface'; +import { + DeleteOutlined, + DownloadOutlined, + ExportOutlined, + InboxOutlined, + PlusOutlined, + SearchOutlined, + UndoOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; + +export interface RoomsToolbarProps { + onSearch: (value: string) => void; + buildings: string[]; + filterBuilding?: string; + onFilterBuilding: (value?: string) => void; + filterStatus?: string; + onFilterStatus: (value?: string) => void; + filterRentalCategory?: string; + onFilterRentalCategory: (value?: string) => void; + showArchived: boolean; + onToggleArchived: () => void; + selectedRowKeys: number[]; + batchLoading: boolean; + canEditRooms: boolean; + canPurgeRooms: boolean; + canDeleteRooms: boolean; + hasCreatePermission: boolean; + onBatchRestore: () => void; + onBatchPurge: () => void; + onBatchDelete: () => void; + onAddRoom: () => void; + onImport: (options: UploadRequestOption<{ message?: string }>) => void; + onDownloadTemplate: () => void; + onExport: () => void; +} + +export const RoomsToolbar: React.FC = ({ + onSearch, + buildings, + filterBuilding, + onFilterBuilding, + filterStatus, + onFilterStatus, + filterRentalCategory, + onFilterRentalCategory, + showArchived, + onToggleArchived, + selectedRowKeys, + batchLoading, + canEditRooms, + canPurgeRooms, + canDeleteRooms, + hasCreatePermission, + onBatchRestore, + onBatchPurge, + onBatchDelete, + onAddRoom, + onImport, + onDownloadTemplate, + onExport, +}) => { + return ( +
+ +

宿舍管理

+ } + /> + + setFilterBuilding(v)} - options={buildings.map((b) => ({ value: b, label: b }))} - /> - - -
- - {showArchived && canEditRooms ? ( - - - - ) : !showArchived && canDeleteRooms ? ( - - - - ) : null} - {!showArchived ? ( - } - onClick={() => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }} - > - 添加宿舍 - - ) : null} - {!showArchived && hasPermission('room:create') ? ( - ) => { - const { file, onSuccess, onError } = options; - if (typeof file === 'string') { - message.error('不支持字符串文件'); - return; - } - try { - const formData = new FormData(); - formData.append('file', file); - const res = await api.post<{ message?: string }>('/rooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - message.success(res.message || '导入成功'); - onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '导入失败'); - onError?.(e as UploadRequestError); - } - }} - > - - - ) : null} - } - onClick={handleDownloadTemplate} - > - 下载模板 - - } onClick={handleExport}> - 导出列表 - - -
-
}} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - showTotal: (total) => `共 ${total} 间`, + { + setShowArchived(!showArchived); + setFilterStatus(undefined); + setSelectedRowKeys([]); }} - rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), + selectedRowKeys={selectedRowKeys} + batchLoading={batchLoading} + canEditRooms={canEditRooms} + canPurgeRooms={canPurgeRooms} + canDeleteRooms={canDeleteRooms} + hasCreatePermission={hasPermission('room:create')} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onBatchDelete={handleBatchDelete} + onAddRoom={() => { + setEditing(null); + form.resetFields(); + setModalOpen(true); }} + onImport={async (options: UploadRequestOption<{ message?: string }>) => { + const { file, onSuccess, onError } = options; + if (typeof file === 'string') { + message.error('不支持字符串文件'); + return; + } + try { + const formData = new FormData(); + formData.append('file', file); + const res = await importMutation.mutateAsync(formData); + message.success(res.message || '导入成功'); + onSuccess?.(res); + } catch (e) { + onError?.(e as Error); + } + }} + onDownloadTemplate={handleDownloadTemplate} + onExport={handleExport} /> - - { + + + { setModalOpen(false); setEditing(null); }} - okText="保存" - confirmLoading={saving} - > -
- - { - const parsed = parseRoomNumber(e.target.value); - if (parsed) form.setFieldsValue(parsed); - }} - /> - - - - - - - - - - - - - - - - - {editing && ( - -
( - saveBedCell(r, 'bedNumber', next)} - > - {v} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, r: BedItem) => ( - saveBedCell(r, 'status', next)} - > - {(() => { - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - })()} - - ), - }, - { - title: '备注', - dataIndex: 'notes', - render: (v: string, r: BedItem) => ( - saveBedCell(r, 'notes', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, r: any) => ( - - { - setBedEditing(r); - bedForm.setFieldsValue(r); - setBedModalOpen(true); - }} - > - 编辑 - - {r.status !== 'occupied' && canEditRooms && ( - handleDeleteBed(r.id)} - > - - - )} - - ), - }, - ]} - /> - - ), - }, - { - key: 'lockers', - label: `柜子管理 (${lockers.length})`, - children: ( -
- {canEditRooms ? ( -
- - - } - onConfirm={() => { - const input = document.getElementById( - 'batch-locker-count', - ) as HTMLInputElement; - handleBatchLockers(input ? parseInt(input.value) || 4 : 4); - }} - okText="生成" - disabled={drawerRoom?.status === 'archived'} - > - - -
- ) : null} -
( - saveLockerCell(r, 'lockerNumber', next)} - > - {v} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, r: LockerItem) => ( - saveLockerCell(r, 'status', next)} - > - {(() => { - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - })()} - - ), - }, - { - title: '备注', - dataIndex: 'notes', - render: (v: string, r: LockerItem) => ( - saveLockerCell(r, 'notes', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, r: any) => ( - - { - setLockerEditing(r); - lockerForm.setFieldsValue(r); - setLockerModalOpen(true); - }} - > - 编辑 - - {r.status !== 'occupied' && canEditRooms && ( - handleDeleteLocker(r.id)} - > - - - )} - - ), - }, - ]} - /> - - ), - }, - ]} - /> - - - { + onAddBed={() => { + setBedEditing(null); + bedForm.resetFields(); + setBedModalOpen(true); + }} + onBatchBeds={handleBatchBeds} + onEditBed={(r) => { + setBedEditing(r); + bedForm.setFieldsValue(r); + setBedModalOpen(true); + }} + onDeleteBed={handleDeleteBed} + onSaveBedCell={saveBedCell} + onAddLocker={() => { + setLockerEditing(null); + lockerForm.resetFields(); + setLockerModalOpen(true); + }} + onBatchLockers={handleBatchLockers} + onEditLocker={(r) => { + setLockerEditing(r); + lockerForm.setFieldsValue(r); + setLockerModalOpen(true); + }} + onDeleteLocker={handleDeleteLocker} + onSaveLockerCell={saveLockerCell} + bedModalOpen={bedModalOpen} + bedEditing={!!bedEditing} + savingBed={savingBed} + bedForm={bedForm} + onSaveBed={handleSaveBed} + onCloseBedModal={() => { setBedModalOpen(false); setBedEditing(null); }} - confirmLoading={savingBed} - okText="保存" - > - - - - - - - - -
+ + + + {WEEKDAYS.map((day) => ( + + ))} + + + + {filteredClassrooms.map((classroom) => ( + + + {WEEKDAY_NUMBERS.map((wd) => { + const schedules = displayMatrix[classroom.id]?.[wd] || []; + const hasContent = schedules.length > 0; + return ( + + ); + })} + + ))} + +
+ 教室 + + {day} +
+
{classroom.name}
+ {classroom.building && ( +
+ {classroom.building} + {classroom.floor ? ` ${classroom.floor}F` : ''} +
+ )} +
onCellClick(classroom.id, wd)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCellClick(classroom.id, wd); + } + }} + style={{ + padding: 4, + border: '1px solid #f0f0f0', + verticalAlign: 'top', + cursor: 'pointer', + minHeight: 56, + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.background = '#f6f8fa'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = ''; + }} + > + {hasContent ? ( +
+ {schedules.map((s) => ( + +
+
+ {s.subject} +
+
+ {s.startTime}-{s.endTime} +
+
+
+ ))} +
+ ) : ( +
+ — +
+ )} +
+
+ ) : ( +
+ + + + {WEEKDAYS.map((d) => ( + + ))} + + + + {weeks.map((week, wi) => ( + + {week.map((day, di) => { + const isCurrentMonth = day.month() === monthStart.month(); + const dateKey = day.format('YYYY-MM-DD'); + const daySchedules = monthScheduleMap[dateKey] || []; + const count = daySchedules.length; + return ( + + ); + })} + + ))} + +
+ {d} +
onDateClick(day)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onDateClick(day); + } + }} + style={{ + padding: '6px 8px', + border: '1px solid #f0f0f0', + verticalAlign: 'top', + cursor: 'pointer', + height: 90, + background: isCurrentMonth ? '#fff' : '#fafafa', + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.background = isCurrentMonth + ? '#f0f5ff' + : '#f0f0f0'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = isCurrentMonth + ? '' + : '#fafafa'; + }} + > +
+ {day.date()} +
+ {count > 0 && ( + + )} +
+
+ )} + + ); +}; diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx new file mode 100644 index 0000000..e0b9cfb --- /dev/null +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -0,0 +1,561 @@ +import React from 'react'; +import { + Alert, + Button, + Card, + Col, + DatePicker, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Spin, + Statistic, + Switch, + Tag, + TimePicker, +} from 'antd'; +import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import { isMaskedSchedule } from './schedule-visibility'; +import type { ScheduleFormValues } from './schedule-form'; +import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids'; +import { WEEKDAYS } from './ScheduleGrids'; + +export interface ScheduleModalProps { + open: boolean; + mode: 'create' | 'edit' | 'detail'; + submitting: boolean; + form: ReturnType>[0]; + selectedCell: { classroomId: number; weekDay: number } | null; + selectedDate: Dayjs | null; + selectedSchedules: ClassScheduleItem[]; + editingSchedule: ClassScheduleItem | null; + selectedClassroom?: ClassroomItem; + classOptions: Array<{ value: number; label: string }>; + classroomOptions: Array<{ value: number; label: string }>; + classTeachers: ClassTeacherOption[]; + classes: ClassItem[]; + onCancel: () => void; + onSubmit: () => void; + onStartCreate: () => void; + onEdit: (schedule: ClassScheduleItem) => void; + onDisable: (id: number | null) => void; + onClassChange: (classId: number) => void; + onSubjectBlur: (value: string) => void; +} + +export const ScheduleModal: React.FC = ({ + open, + mode, + submitting, + form, + selectedCell, + selectedDate, + selectedSchedules, + editingSchedule, + selectedClassroom, + classOptions, + classroomOptions, + classTeachers, + classes, + onCancel, + onSubmit, + onStartCreate, + onEdit, + onDisable, + onClassChange, + onSubjectBlur, +}) => { + const title = + mode === 'create' + ? `新增排课 — ${selectedClassroom?.name || ''} · ${ + selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : '' + }` + : mode === 'edit' + ? `编辑排课 — ${editingSchedule?.subject || ''}` + : selectedDate + ? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${ + WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1] + }` + : `排课详情 — ${selectedClassroom?.name || ''} · ${ + selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : '' + }`; + + return ( + + {mode !== 'detail' ? ( + + + + + + onSubjectBlur(event.target.value)} + /> + + + + +
+ + +
+
仅允许考勤机打卡
+
+ 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。 +
+
+
+
+ {attendanceMachineOnly && ( + + )} +
+ {syncStatus.activeSchedules === 0 && ( + + )} +
+ ) : ( + + )} + + ); +}; diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index ce448cb..c188c4c 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -1,42 +1,16 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Card, - Button, - Select, - Modal, - Form, - Input, - InputNumber, - DatePicker, - TimePicker, - Popconfirm, - Space, - Spin, - Empty, - Tag, - Tooltip, - Segmented, - Badge, - Row, - Col, - Statistic, - Alert, - Switch, -} from 'antd'; -import { - CalendarOutlined, - LeftOutlined, - RightOutlined, - CloudSyncOutlined, - PlusOutlined, - EditOutlined, - StopOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useMemo, useState } from 'react'; +import { Button, Card, Form, Segmented, Select, Space } from 'antd'; +import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { scheduleLookupsSchema, weeklyScheduleSchema } from '../../api/schemas'; import { buildSchedulePayload, scheduleToFormValues, @@ -44,52 +18,16 @@ import { } from './schedule-form'; import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility'; import { classifySyncResult } from './sync-result'; +import { getErrorMessage } from '../../utils/error'; +import { ScheduleGrid } from './ScheduleGrids'; +import type { + ClassItem, + ClassScheduleItem, + ClassTeacherOption, + ClassroomItem, +} from './ScheduleGrids'; +import { ScheduleModal, SyncModal } from './ScheduleModals'; -// ---- Types ---- - -interface ClassScheduleItem { - id: number | null; - classId: number | null; - classroomId: number; - weekDay: number; - startTime: string; - endTime: string; - attendanceAdvanceMinutes: number; - startDate: string; - endDate: string; - subject: string; - teacherId: number | null; - scheduleType: string; - status: string; - notes: string | null; - createdAt: string; - updatedAt: string; - canViewDetails?: boolean; -} - -interface ClassroomItem { - id: number; - name: string; - building: string; - floor: number; - roomType: string; -} - -interface ClassItem { - id: number; - name: string; - code: string; -} - -interface ClassTeacherOption { - id: number; - userId: number; - username?: string; - name?: string; - roleType: string; - subject?: string | null; -} -/** 排班同步返回结果 */ interface ScheduleSyncResult { scheduleCount: number; shiftCount: number; @@ -102,32 +40,14 @@ interface ScheduleSyncResult { groups: Array<{ className: string; groupId: number; itemCount: number }>; } -const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; -const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7]; - -// ---- Component ---- - const SchedulesPage: React.FC = () => { const { hasPermission } = usePermission(); - // View mode and navigation const [viewMode, setViewMode] = useState<'week' | 'month'>('week'); const [viewDate, setViewDate] = useState(() => dayjs().weekday(1).startOf('day')); - - // Modal date selection (month view) const [selectedDate, setSelectedDate] = useState(null); - - // Data - const [classrooms, setClassrooms] = useState([]); - const [classes, setClasses] = useState([]); const [classTeachers, setClassTeachers] = useState([]); - const [matrix, setMatrix] = useState>>({}); - const [loading, setLoading] = useState(false); - - // Filters const [filterClassroomIds, setFilterClassroomIds] = useState([]); const [filterClassId, setFilterClassId] = useState(undefined); - - // Modal const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create'); const [editingSchedule, setEditingSchedule] = useState(null); @@ -137,8 +57,6 @@ const SchedulesPage: React.FC = () => { } | null>(null); const [selectedSchedules, setSelectedSchedules] = useState([]); const [submitting, setSubmitting] = useState(false); - - // ── 钉钉排班同步 ── const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncing, setSyncing] = useState(false); const [syncStatus, setSyncStatus] = useState<{ @@ -146,23 +64,13 @@ const SchedulesPage: React.FC = () => { mappedClasses: number; totalClasses: number; } | null>(null); - const [syncResult, setSyncResult] = useState<{ - scheduleCount: number; - shiftCount: number; - groupCount: number; - syncedItems: number; - skippedNoMapping: number; - failedBatchCount: number; - failedItems: number; - errors: string[]; - groups: Array<{ className: string; groupId: number; itemCount: number }>; - } | null>(null); + const [syncResult, setSyncResult] = useState(null); const [syncDateFrom, setSyncDateFrom] = useState(dayjs); const [syncDays, setSyncDays] = useState(30); const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false); + const [form] = Form.useForm(); - /** 打开同步弹窗时先查询就绪状态 */ - const openSyncModal = useCallback(async () => { + const openSyncModal = async () => { setSyncModalOpen(true); setSyncResult(null); try { @@ -174,10 +82,9 @@ const SchedulesPage: React.FC = () => { } catch { setSyncStatus(null); } - }, []); + }; - /** 执行排班同步 */ - const handleSyncSchedule = useCallback(async () => { + const handleSyncSchedule = async () => { setSyncing(true); try { const res = await api.post<{ @@ -200,15 +107,12 @@ const SchedulesPage: React.FC = () => { message.success(classification.message); } } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '同步失败'); + message.error(getErrorMessage(e, '同步失败')); } finally { setSyncing(false); } - }, [syncDateFrom, syncDays, attendanceMachineOnly]); - const [form] = Form.useForm(); + }; - // Derived week/month info const weekStart = useMemo(() => viewDate.weekday(1).startOf('day'), [viewDate]); const monthStart = useMemo(() => viewDate.startOf('month'), [viewDate]); const weekEnd = useMemo(() => weekStart.add(6, 'day'), [weekStart]); @@ -237,64 +141,83 @@ const SchedulesPage: React.FC = () => { }, [calendarDays]); const startDateStr = useMemo(() => { - if (viewMode === 'month') { - return calendarDays[0].format('YYYY-MM-DD'); - } + if (viewMode === 'month') return calendarDays[0].format('YYYY-MM-DD'); return weekStart.format('YYYY-MM-DD'); }, [viewMode, weekStart, calendarDays]); const endDateStr = useMemo(() => { - if (viewMode === 'month') { - return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD'); - } + if (viewMode === 'month') return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD'); return weekEnd.format('YYYY-MM-DD'); }, [viewMode, weekEnd, calendarDays]); - // ---- Data fetching ---- - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [lookups, schedulesRes] = await Promise.all([ - api.get('/class-schedules/lookups') as Promise<{ + const { + data: fetchResult = { classrooms: [], classes: [], matrix: {} }, + isLoading, + isFetching, + } = useQuery<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + matrix: Record>; + }>({ + queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds], + queryFn: async () => { + try { + const [lookups, schedulesRes] = await Promise.all([ + api.get('/class-schedules/lookups') as Promise<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + }>, + api.get('/class-schedules/weekly', { + params: { + startDate: startDateStr, + endDate: endDateStr, + ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), + }, + }) as Promise>>, + ]); + const validatedLookups = validateResponse<{ classrooms: ClassroomItem[]; classes: ClassItem[]; - }>, - api.get('/class-schedules/weekly', { - params: { - startDate: startDateStr, - endDate: endDateStr, - ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), - }, - }) as Promise>>, - ]); + }>(scheduleLookupsSchema, lookups); + const validatedWeekly = validateResponse< + Record> + >(weeklyScheduleSchema, schedulesRes); - setClassrooms(lookups.classrooms); - setClasses(lookups.classes); - - // Convert string keys to numbers - const typedMatrix: Record> = {}; - for (const [cId, dayMap] of Object.entries(schedulesRes)) { - const classroomId = Number(cId); - typedMatrix[classroomId] = {}; - for (const [wd, schedules] of Object.entries(dayMap)) { - typedMatrix[classroomId][Number(wd)] = schedules; + const typedMatrix: Record> = {}; + for (const [cId, dayMap] of Object.entries(validatedWeekly)) { + const classroomId = Number(cId); + typedMatrix[classroomId] = {}; + for (const [wd, schedules] of Object.entries(dayMap)) { + typedMatrix[classroomId][Number(wd)] = schedules; + } } + return { + classrooms: validatedLookups.classrooms, + classes: validatedLookups.classes, + matrix: typedMatrix, + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载排课数据失败')); + return { classrooms: [], classes: [], matrix: {} }; } - setMatrix(typedMatrix); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载排课数据失败'); - } finally { - setLoading(false); - } - }, [startDateStr, endDateStr, filterClassroomIds]); + }, + }); + const classrooms = fetchResult.classrooms; + const classes = fetchResult.classes; + const matrix = fetchResult.matrix; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); - - // ---- Filtered classrooms ---- + const saveMutation = useApiMutation( + async (payload: Record) => + modalMode === 'edit' && editingSchedule + ? api.put(`/class-schedules/${editingSchedule.id}`, payload) + : api.post('/class-schedules', payload), + { invalidate: [['class-schedules']] }, + ); + const disableMutation = useApiMutation( + async (id: number) => api.put(`/class-schedules/${id}`, { status: 'inactive' }), + { invalidate: [['class-schedules']] }, + ); const filteredClassrooms = useMemo(() => { if (filterClassroomIds.length === 0) return classrooms; @@ -302,7 +225,6 @@ const SchedulesPage: React.FC = () => { return classrooms.filter((c) => idSet.has(c.id)); }, [classrooms, filterClassroomIds]); - // Apply class filter to the matrix const displayMatrix = useMemo(() => { if (filterClassId == null) return matrix; const filtered: Record> = {}; @@ -338,12 +260,10 @@ const SchedulesPage: React.FC = () => { return map; }, [calendarDays, displayMatrix, filteredClassrooms]); - // ---- Cell click handlers ---- const handleCellClick = (classroomId: number, weekDay: number) => { const schedules = displayMatrix[classroomId]?.[weekDay] || []; setSelectedCell({ classroomId, weekDay }); setSelectedDate(null); - if (schedules.length > 0) { setSelectedSchedules(schedules); setModalMode('detail'); @@ -383,7 +303,7 @@ const SchedulesPage: React.FC = () => { setModalOpen(true); }; - const loadClassTeachers = useCallback(async (classId: number) => { + const loadClassTeachers = async (classId: number) => { try { const teachers = await api.get( `/class-schedules/classes/${classId}/teachers`, @@ -394,27 +314,22 @@ const SchedulesPage: React.FC = () => { setClassTeachers([]); return []; } - }, []); + }; - const applyClassTeacherDefaults = useCallback( - async (classId: number, subject?: string) => { - const teachers = await loadClassTeachers(classId); - const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher'); - const matchedBySubject = subject - ? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject) - : []; - const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers; - if (matched.length === 1) { - form.setFieldValue('teacherId', matched[0].userId); - if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject); - } else { - form.setFieldValue('teacherId', undefined); - } - }, - [form, loadClassTeachers], - ); - - // ---- Create / edit schedule ---- + const applyClassTeacherDefaults = async (classId: number, subject?: string) => { + const teachers = await loadClassTeachers(classId); + const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher'); + const matchedBySubject = subject + ? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject) + : []; + const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers; + if (matched.length === 1) { + form.setFieldValue('teacherId', matched[0].userId); + if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject); + } else { + form.setFieldValue('teacherId', undefined); + } + }; const handleSubmit = async () => { if (modalMode === 'create' && !selectedCell) return; @@ -423,20 +338,14 @@ const SchedulesPage: React.FC = () => { const values = (await form.validateFields()) as ScheduleFormValues; setSubmitting(true); const payload = buildSchedulePayload(values); - - if (modalMode === 'edit' && editingSchedule) { - await api.put(`/class-schedules/${editingSchedule.id}`, payload); - message.success('排课更新成功,请重新同步到钉钉排班'); - } else { - await api.post('/class-schedules', payload); - message.success('排课创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success( + modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功', + ); setModalOpen(false); setEditingSchedule(null); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string; status?: number }; - message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败')); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSubmitting(false); } @@ -448,11 +357,16 @@ const SchedulesPage: React.FC = () => { message.warning('租赁排课请在租赁订单中修改'); return; } - const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId }; setEditingSchedule(schedule); setModalMode('edit'); - form.setFieldsValue(scheduleToFormValues(editableSchedule)); - void loadClassTeachers(editableSchedule.classId); + form.setFieldsValue( + scheduleToFormValues({ + ...schedule, + id: schedule.id ?? undefined, + classId: schedule.classId ?? undefined, + }), + ); + void loadClassTeachers(schedule.classId); }; const removeScheduleFromSelection = (id: number) => { @@ -463,23 +377,17 @@ const SchedulesPage: React.FC = () => { } }; - // ---- Disable / delete schedule ---- - const handleDisable = async (id: number | null) => { if (id === null) return; try { - await api.put(`/class-schedules/${id}`, { status: 'inactive' }); + await disableMutation.mutateAsync(id); message.success('排课已停用,历史考勤记录已保留,教室占用已释放'); removeScheduleFromSelection(id); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '停用失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - // ---- Classroom select options ---- - const classroomOptions = useMemo( () => classrooms.map((c) => ({ @@ -498,15 +406,12 @@ const SchedulesPage: React.FC = () => { [classes], ); - // ---- Render ---- - const selectedClassroom = selectedCell ? classrooms.find((c) => c.id === selectedCell.classroomId) : undefined; return (
- {/* Header */}
{ permission="sync:trigger" type="primary" icon={} - onClick={openSyncModal} + onClick={() => void openSyncModal()} > 同步到钉钉排班 {viewMode === 'week' ? ( <> - @@ -561,19 +463,11 @@ const SchedulesPage: React.FC = () => { ) : ( <> - - - {monthStart.format('YYYY年 M月')} - - @@ -581,7 +475,6 @@ const SchedulesPage: React.FC = () => {
- {/* Filters */} { - form.setFieldValue('teacherId', undefined); - void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); - }} - /> - + onSubmit={handleSubmit} + onStartCreate={() => { + setEditingSchedule(null); + setModalMode('create'); + form.resetFields(); + form.setFieldsValue({ + classroomId: selectedCell?.classroomId, + weekDay: + selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), + dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, + attendanceAdvanceMinutes: 30, + }); + }} + onEdit={openEditSchedule} + onDisable={handleDisable} + onClassChange={(classId) => { + form.setFieldValue('teacherId', undefined); + void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); + }} + onSubjectBlur={(value) => { + const classId = form.getFieldValue('classId'); + if (classId) void applyClassTeacherDefaults(classId, value); + }} + /> - - ({ value, label: WEEKDAYS[value - 1] }))} - /> - - - - { - const classId = form.getFieldValue('classId'); - if (classId) void applyClassTeacherDefaults(classId, event.target.value); - }} - /> - - - - - -
- - -
-
仅允许考勤机打卡
-
- 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。 -
-
-
-
- {attendanceMachineOnly && ( - - )} -
- {syncStatus.activeSchedules === 0 && ( - - )} - - ) : ( - - )} - + onSync={handleSyncSchedule} + onDateChange={setSyncDateFrom} + onDaysChange={setSyncDays} + onMachineOnlyChange={setAttendanceMachineOnly} + /> ); }; diff --git a/apps/admin/src/pages/Students/StudentColumns.tsx b/apps/admin/src/pages/Students/StudentColumns.tsx new file mode 100644 index 0000000..6bbf4bc --- /dev/null +++ b/apps/admin/src/pages/Students/StudentColumns.tsx @@ -0,0 +1,388 @@ +// aislop-ignore-file: duplicate-block -- 单元格渲染结构相似且字段不同,逻辑已通过 EditableStudentCell 共享 +import React from 'react'; +import { Button, Popconfirm, Space, Tag } from 'antd'; +import { EyeOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import { maskIdNumber, maskPhone } from '../../utils/sensitive'; + +export const statusMap: Record = { + active: { text: '在读', color: 'green' }, + graduated: { text: '已毕业', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const STUDENT_FIELDS = { + name: 'name', + studentNo: 'studentNo', + ethnicity: 'ethnicity', + emergencyContact: 'emergencyContact', + supervisor: 'supervisor', + status: 'status', + organizationId: 'organizationId', +} as const; + +export const SENSITIVE_LABELS = { + phone: '电话', + idNumber: '身份证号', + emergencyPhone: '紧急联系人电话', +} as const; + +export const STUDENT_STATUS_OPTIONS = [ + { value: 'active', label: '在读' }, + { value: 'graduated', label: '已毕业' }, + { value: 'withdrawn', label: '已退训' }, +]; + +export interface StudentColumnContext { + pageInfo: { current: number; pageSize: number }; + organizations: Array<{ id: number; name: string; isHost?: boolean }>; + canChooseOrganization: boolean; + canEditStudent: boolean; + canDeleteStudent: boolean; + canPurgeStudent: boolean; + canViewSensitive: boolean; + onSaveCell: (record: any, field: string, value: unknown) => Promise | void; + onViewSensitive: (recordId: number, field: string, value: string) => void; + onOpenDrawer: (recordId: number) => void; + onEdit: (record: any) => void; + onRestore: (id: number) => Promise | unknown; + onPurge: (id: number, name: string) => void; + onArchive: (id: number) => Promise | unknown; +} + +export const EditableStudentCell = ({ + value, + field, + record, + editor, + min, + max, + required, + options, + onSave, + children, +}: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + max?: number; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; +}) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + +); + +export const SensitiveValue: React.FC<{ + value: string; + masked: string; + label: string; + recordId: number; + canViewSensitive: boolean; + onViewSensitive: (recordId: number, field: string, value: string) => void; +}> = ({ value, masked, label, recordId, canViewSensitive, onViewSensitive }) => { + if (!value) return <>-; + return ( + + {masked} + {canViewSensitive ? ( + + ) : null} + + ); +}; + +function buildIdentityColumns(ctx: StudentColumnContext) { + const { + pageInfo, + canViewSensitive, + onSaveCell, + onViewSensitive, + } = ctx; + + return [ + { + title: '序号', + key: 'index', + width: 70, + render: (_: unknown, __: unknown, index: number) => + (pageInfo.current - 1) * pageInfo.pageSize + index + 1, + }, + { + title: '姓名', + dataIndex: 'name', + width: 120, + render: (v: string, record: any) => ( + + {v} + + ), + }, + { + title: '电话', + dataIndex: 'phone', + width: 140, + render: (v: string, record: any) => ( + + ), + }, + { + title: '学号', + dataIndex: 'studentNo', + width: 120, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '身份证', + dataIndex: 'idNumber', + width: 180, + render: (v: string, record: any) => ( + + ), + }, + ]; +} + +function buildContactColumns(ctx: StudentColumnContext) { + const { organizations, canChooseOrganization, canViewSensitive, onSaveCell, onViewSensitive } = + ctx; + return [ + { + title: '民族', + dataIndex: 'ethnicity', + width: 90, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '紧急联系人', + dataIndex: 'emergencyContact', + width: 100, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '紧急联系人电话', + dataIndex: 'emergencyPhone', + width: 150, + render: (v: string, record: any) => ( + + ), + }, + { + title: '所属机构', + dataIndex: 'organization', + width: 100, + render: (organization: { name?: string } | null, record: any) => + canChooseOrganization ? ( + ({ value: item.id, label: item.name }))} + required + onSave={onSaveCell} + > + {organization?.name ? ( + + {organization.name} + + ) : ( + '-' + )} + + ) : organization?.name ? ( + {organization.name} + ) : ( + '-' + ), + }, + ]; +} + +function buildProfileColumns(ctx: StudentColumnContext) { + const { onSaveCell } = ctx; + return [ + { + title: '负责人', + dataIndex: 'supervisor', + width: 100, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, record: any) => ( + + + {statusMap[s]?.text || s} + + + ), + }, + ]; +} + +function buildActionColumn(ctx: StudentColumnContext) { + const { + canEditStudent, + canDeleteStudent, + canPurgeStudent, + onOpenDrawer, + onEdit, + onRestore, + onPurge, + onArchive, + } = ctx; + return { + title: '操作', + width: 180, + render: (_: any, record: any) => ( + + {record.status === 'archived' ? ( + <> + {canEditStudent ? ( + onRestore(record.id)} + okText="恢复" + cancelText="取消" + > + + + ) : null} + {canPurgeStudent ? ( + + ) : null} + + ) : ( + <> + onOpenDrawer(record.id)} + > + 档案 + + onEdit(record)}> + 编辑 + + {canDeleteStudent ? ( + onArchive(record.id)} + okText="归档" + cancelText="取消" + > + + + ) : null} + + )} + + ), + }; +} + +export function buildStudentColumns(ctx: StudentColumnContext) { + return [ + ...buildIdentityColumns(ctx), + ...buildContactColumns(ctx), + ...buildProfileColumns(ctx), + buildActionColumn(ctx), + ]; +} diff --git a/apps/admin/src/pages/Students/StudentModals.tsx b/apps/admin/src/pages/Students/StudentModals.tsx new file mode 100644 index 0000000..a69c394 --- /dev/null +++ b/apps/admin/src/pages/Students/StudentModals.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd'; +import JinshujuMatchModal from '../../components/JinshujuMatchModal'; +import StudentProfileContent from '../../components/StudentProfileContent'; +import { SENSITIVE_LABELS } from './StudentColumns'; + +type AppModal = ReturnType['modal']; + +export const showCreateImportResult = ( + modal: AppModal, + result: { message?: string; imported?: number; skipped?: number }, +) => { + const imported = result.imported ?? 0; + const skipped = result.skipped ?? 0; + modal.success({ + title: '导入完成', + okText: '知道了', + content: ( +
+ + {imported} 人 + {skipped} 人 + +
跳过原因:
+
    +
  • 姓名为空
  • +
  • 已存在同名学生
  • +
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 +
+
+ ), + }); +}; + +export const showUpdateImportResult = ( + modal: AppModal, + result: { message?: string; matched?: number; skipped?: number }, +) => { + const matched = result.matched ?? 0; + const skipped = result.skipped ?? 0; + modal.success({ + title: '更新完成', + okText: '知道了', + content: ( +
+ + {matched} 人 + {skipped} 人 + +
匹配规则:
+
手机号优先,身份证号其次
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 +
+
+ ), + }); +}; + +export const StudentEditModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + canChooseOrganization: boolean; + organizations: Array<{ id: number; name: string; isHost?: boolean }>; + onOk?: () => void; + onCancel: () => void; +}> = ({ + open, + editing, + saving, + form, + canChooseOrganization, + organizations, + onOk, + onCancel, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + {canChooseOrganization ? ( + + + + {editing && ( + + + {Object.entries(statusMap) + .filter(([k]) => k !== 'archived') + .map(([k, v]) => ( + + {v.text} + + ))} + + {canViewOrganizations ? ( + + ) : null} + ({ + value: item.id, + label: item.name === item.username ? item.name : `${item.name}(${item.username})`, + }))} + /> + + + + {showArchived && canEditStudent ? ( + <> + + + + {canPurgeStudent ? ( + + + + ) : null} + + ) : !showArchived && canDeleteStudent ? ( + + + + ) : null} + {!showArchived ? ( + } + onClick={onAddStudent} + > + 添加学生 + + ) : null} + {!showArchived && ( + <> + + + + + + + + )} + {!showArchived && canSyncJinshuju ? ( + + ) : null} + {!showArchived && canSyncDingTalk ? ( + + ) : null} + } + onClick={onDownloadTemplate} + > + 下载模板 + + } + onClick={onExport} + > + 导出名单 + + + + + ); +}; diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 5e70cea..c2a1d26 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,73 +1,33 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Alert, App, - Button, - Card, - Col, - Descriptions, - Drawer, - Empty, Form, - Input, - Modal, - Popconfirm, - Row, - Select, - Space, - Table, - Tag, - Upload, } from 'antd'; -import type { UploadProps } from 'antd'; -import { - CloudUploadOutlined, - DownloadOutlined, - ExportOutlined, - EyeOutlined, - InboxOutlined, - PlusOutlined, - SwapOutlined, - SyncOutlined, - UndoOutlined, - UploadOutlined, -} from '@ant-design/icons'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; -import StudentProfileContent from '../../components/StudentProfileContent'; -import EditableCell from '../../components/EditableCell'; -import JinshujuMatchModal from '../../components/JinshujuMatchModal'; -import { maskIdNumber, maskPhone } from '../../utils/sensitive'; -import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; import { selectArchiveRecords } from '../archive-view'; - -const statusMap: Record = { - active: { text: '在读', color: 'green' }, - graduated: { text: '已毕业', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, - archived: { text: '已归档', color: '#999' }, -}; - -interface EnrollmentInfo { - classId: number; - className: string; - classType: string; - startDate: string; - endDate: string; - joinDate: string; - leaveDate: string; - status: string; - attendanceStats: { - total: number; - present: number; - absent: number; - late: number; - leave: number; - rate: number; - }; -} +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { + organizationOptionsSchema, + organizationsSchema, + studentFilterLookupsSchema, + studentsSchema, +} from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; +import { message } from '../../ui/app-message'; +import { buildStudentColumns } from './StudentColumns'; +import { StudentsToolbar } from './StudentsToolbar'; +import { + JinshujuModal, + StudentDrawer, + StudentEditModal, + showCreateImportResult, + showUpdateImportResult, +} from './StudentModals'; +import { StudentsTable } from './StudentsTable'; interface StudentCreateImportResult { message?: string; @@ -110,50 +70,34 @@ const StudentsPage: React.FC = () => { const canCreateStudent = hasPermission('student:create'); const canEditStudent = hasPermission('student:edit'); const canDeleteStudent = hasPermission('student:delete'); + const canPurgeStudent = hasPermission('student:purge'); const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger'); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); - const [organizations, setOrganizations] = useState([]); const [editing, setEditing] = useState(null); const canSaveStudent = editing ? canEditStudent : canCreateStudent; const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); + const effectiveFilterOrganizationId = canLoadOrganizations ? filterOrganizationId : undefined; const [filterClassId, setFilterClassId] = useState(undefined); const [filterTeacherId, setFilterTeacherId] = useState(undefined); - const [classOptions, setClassOptions] = useState([]); - const [teacherOptions, setTeacherOptions] = useState([]); const [showArchived, setShowArchived] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [dingSyncLoading, setDingSyncLoading] = useState(false); - const [enrollmentData, setEnrollmentData] = useState>({}); const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 }); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerStudentId, setDrawerStudentId] = useState(undefined); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); + const [jinshujuOpen, setJinshujuOpen] = useState(false); const openDrawer = (studentId: number) => { setDrawerStudentId(studentId); setDrawerOpen(true); }; - const [jinshujuOpen, setJinshujuOpen] = useState(false); - - // Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts. - // Close the student form modal when the user loses the required permission. - useEffect(() => { - if (!canSaveStudent && modalOpen) { - setModalOpen(false); - setEditing(null); - form.resetFields(); - } - }, [canSaveStudent, modalOpen, form]); - - // Close sensitive modal when log:create is lost (imperative ref already set above). const logCreateRef = React.useRef(hasPermission('log:create')); const sensitiveModalRef = React.useRef | null>(null); logCreateRef.current = hasPermission('log:create'); @@ -190,7 +134,8 @@ const StudentsPage: React.FC = () => { content: value, okText: '关闭', }); - } catch { + } catch (e) { + console.error('审计日志记录失败', e); message.error('审计日志记录失败,请稍后重试'); } }, @@ -200,16 +145,235 @@ const StudentsPage: React.FC = () => { }); }; + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: [ + 'students', + searchName, + showArchived, + filterStatus, + effectiveFilterOrganizationId, + filterClassId, + filterTeacherId, + ], + queryFn: async () => { + try { + const params: Record = { + name: searchName || undefined, + includeArchived: showArchived ? 'true' : undefined, + }; + if (showArchived) params.status = 'archived'; + else if (filterStatus) params.status = filterStatus; + if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; + if (filterClassId) params.classId = filterClassId; + if (filterTeacherId) params.teacherId = filterTeacherId; + const res = (await api.get('/students', { params })) as Array>; + return selectArchiveRecords( + validateResponse>>(studentsSchema, res), + showArchived ? 'archived' : 'active', + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const queryClient = useQueryClient(); + const invalidateStudents: Array = [['students']]; + const saveMutation = useApiMutation( + async (values: Record) => + editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values), + { invalidate: invalidateStudents }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/students/${record.id}`, { [field]: value }), + { invalidate: invalidateStudents }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/students/${id}`), + { invalidate: invalidateStudents }, + ); + const restoreMutation = useApiMutation( + async (id: number) => api.put(`/students/${id}/restore`), + { invalidate: invalidateStudents }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/students/${id}/permanent`), + { invalidate: invalidateStudents }, + ); + const batchDeleteMutation = useApiMutation( + async (ids: number[]) => api.post('/students/batch-delete', { ids }), + { invalidate: invalidateStudents }, + ); + const batchRestoreMutation = useApiMutation( + async (ids: number[]) => + api.put<{ message?: string; restored: number; skipped: number }>( + '/students/batch-restore', + { ids }, + ), + { invalidate: invalidateStudents }, + ); + const batchPurgeMutation = useApiMutation( + async (ids: number[]) => api.post('/students/batch-permanent-delete', { ids }), + { invalidate: invalidateStudents }, + ); + const importMutation = useApiMutation( + async (formData: FormData) => + api.post('/students/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateStudents }, + ); + const importMatchMutation = useApiMutation( + async (formData: FormData) => + api.post('/students/import-match', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateStudents }, + ); + + const { data: organizations = [] } = useQuery< + Array<{ id: number; name: string; isHost?: boolean }> + >({ + queryKey: ['students', 'organizations', canViewOrganizations], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + if (canViewOrganizations) { + return validateResponse>( + organizationsSchema, + await api.get('/organizations', { + params: { includeArchived: 'false' }, + }), + ); + } + return validateResponse>( + organizationOptionsSchema, + await api.get('/organizations/options'), + ); + } catch { + return []; + } + }, + }); + const { data: lookups = { classes: [], teachers: [] } } = useQuery({ + queryKey: ['students', 'filter-lookups'], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + return validateResponse( + studentFilterLookupsSchema, + await api.get('/students/filter-lookups'), + ); + } catch { + return { classes: [], teachers: [] }; + } + }, + }); + const classOptions = lookups.classes || []; + const teacherOptions = lookups.teachers || []; + + const handleSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await saveMutation.mutateAsync(values); + message.success(editing ? '更新成功' : '创建成功'); + setModalOpen(false); + form.resetFields(); + setEditing(null); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); + } + }; + + const saveCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [saveCellMutation], + ); + + const downloadApiFile = async (path: string, filename: string, errorMessage = '下载失败') => { + const baseURL = import.meta.env.PROD + ? '/api' + : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; + const token = useUserStore.getState().token; + try { + const res = await fetch(`${baseURL}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch (error: unknown) { + console.error(errorMessage, error); + message.error(errorMessage); + } + }; + + const handleArchive = async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handleRestore = async (id: number) => { + try { + await restoreMutation.mutateAsync(id); + message.success('已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除学生「${name}」?`, + content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleBatchDelete = async () => { if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys }); + const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -219,241 +383,59 @@ const StudentsPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ message?: string; restored: number; skipped: number }>( - '/students/batch-restore', - { ids: selectedRowKeys }, - ); + const res = await batchRestoreMutation.mutateAsync(selectedRowKeys); message.success( `已批量恢复 ${res.restored} 人${res.skipped ? `,跳过 ${res.skipped} 人` : ''}`, ); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); + const handleBatchPurge = async () => { + if (batchLoading) return; + setBatchLoading(true); try { - const params: Record = { - name: searchName || undefined, - includeArchived: showArchived ? 'true' : undefined, - }; - if (showArchived) params.status = 'archived'; - else if (filterStatus) params.status = filterStatus; - if (filterOrganizationId) params.organizationId = filterOrganizationId; - if (filterClassId) params.classId = filterClassId; - if (filterTeacherId) params.teacherId = filterTeacherId; - const res = (await api.get('/students', { params })) as Array>; - const list = res as Array>; - setData(selectArchiveRecords(list, showArchived ? 'archived' : 'active')); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [ - searchName, - showArchived, - filterStatus, - filterOrganizationId, - filterClassId, - filterTeacherId, - ]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!canLoadOrganizations) { - setOrganizations([]); - setFilterOrganizationId(undefined); - return; - } - if (canViewOrganizations) { - api - .get('/organizations', { params: { includeArchived: 'false' } }) - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - } else { - api - .get('/organizations/options') - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - } - api - .get('/students/filter-lookups') - .then((res) => { - setClassOptions(res.classes || []); - setTeacherOptions(res.teachers || []); - }) - .catch(() => {}); - }, [canLoadOrganizations]); - const handleSave = async () => { - const values = await form.validateFields(); - setSaving(true); - try { - if (editing) { - await api.put(`/students/${editing.id}`, values); - message.success('更新成功'); - } else { - await api.post('/students', values); - message.success('创建成功'); - } - setModalOpen(false); - form.resetFields(); - setEditing(null); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys); + message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 人`); + setSelectedRowKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { - setSaving(false); - } - }; - - const saveCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/students/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - - const handleArchive = async (id: number) => { - try { - await api.delete(`/students/${id}`); - message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }; - - const handleRestore = async (id: number) => { - try { - await api.put(`/students/${id}/restore`); - message.success('已恢复'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '恢复失败'); + setBatchLoading(false); } }; const handleDownloadTemplate = () => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; - fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } }) - .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '学生导入模板.xlsx'; - a.click(); - URL.revokeObjectURL(url); - }) - .catch(() => message.error('下载失败')); + void downloadApiFile('/students/template', '学生导入模板.xlsx'); }; - const showCreateImportResult = (result: StudentCreateImportResult) => { - const imported = result.imported ?? 0; - const skipped = result.skipped ?? 0; - - modal.success({ - title: '导入完成', - okText: '知道了', - content: ( -
- - {imported} 人 - {skipped} 人 - -
跳过原因:
-
    -
  • 姓名为空
  • -
  • 已存在同名学生
  • -
-
- 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 -
-
- ), - }); - }; - - const showUpdateImportResult = (result: StudentUpdateImportResult) => { - const matched = result.matched ?? 0; - const skipped = result.skipped ?? 0; - - modal.success({ - title: '更新完成', - okText: '知道了', - content: ( -
- - {matched} 人 - {skipped} 人 - -
匹配规则:
-
手机号优先,身份证号其次
-
- 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 -
-
- ), - }); - }; - - const handleCreateStudentsImport: UploadProps['customRequest'] = async ({ - file, - onSuccess, - onError, - }) => { + const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); try { - const res = (await api.post('/students/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - })) as StudentCreateImportResult; - showCreateImportResult(res); + const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult; + showCreateImportResult(modal, res); onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '导入失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败')); + } catch (e) { + onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败'))); } }; - const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({ - file, - onSuccess, - onError, - }) => { + const handleUpdateExistingStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); try { - const res = (await api.post('/students/import-match', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - })) as StudentUpdateImportResult; - showUpdateImportResult(res); + const res = (await importMatchMutation.mutateAsync(formData)) as StudentUpdateImportResult; + showUpdateImportResult(modal, res); onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '更新已有学生资料失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败')); + } catch (e) { + onError?.( + e instanceof Error ? e : new Error(getErrorMessage(e, '更新已有学生资料失败')), + ); } }; @@ -470,723 +452,144 @@ const StudentsPage: React.FC = () => { } else { message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`); } - await fetchData(); + void queryClient.invalidateQueries({ queryKey: ['students'] }); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '钉钉同步失败'); + message.error(getErrorMessage(e, '钉钉同步失败')); } finally { setDingSyncLoading(false); } }; const handleExport = () => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; const params = new URLSearchParams(); if (searchName) params.set('name', searchName); if (filterStatus) params.set('status', filterStatus); - if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId)); + if (effectiveFilterOrganizationId) + params.set('organizationId', String(effectiveFilterOrganizationId)); if (showArchived) params.set('includeArchived', 'true'); if (filterClassId) params.set('classId', String(filterClassId)); if (filterTeacherId) params.set('teacherId', String(filterTeacherId)); const query = params.toString() ? `?${params.toString()}` : ''; - fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } }) - .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '学生名单.xlsx'; - a.click(); - URL.revokeObjectURL(url); - }) - .catch(() => message.error('导出失败')); + void downloadApiFile(`/students/export${query}`, '学生名单.xlsx', '导出失败'); }; const columns = useMemo( - () => [ - { - title: '序号', - key: 'index', - width: 70, - render: (_: unknown, __: unknown, index: number) => - (pageInfo.current - 1) * pageInfo.pageSize + index + 1, - }, - { - title: '姓名', - dataIndex: 'name', - width: 120, - render: (v: string, record: any) => ( - saveCell(record, 'name', next)} - > - {v} - - ), - }, - { - title: '电话', - dataIndex: 'phone', - width: 140, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskPhone(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); + () => + buildStudentColumns({ + pageInfo, + organizations, + canChooseOrganization, + canEditStudent, + canDeleteStudent, + canPurgeStudent, + canViewSensitive: hasPermission('log:create'), + onSaveCell: saveCell, + onViewSensitive: handleViewSensitive, + onOpenDrawer: openDrawer, + onEdit: (record) => { + setEditing(record); + form.setFieldsValue(record); + setModalOpen(true); }, - }, - { - title: '学号', - dataIndex: 'studentNo', - width: 120, - render: (v: string, record: any) => ( - saveCell(record, 'studentNo', next)} - > - {v || '-'} - - ), - }, - { - title: '身份证', - dataIndex: 'idNumber', - width: 180, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskIdNumber(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); - }, - }, - { - title: '民族', - dataIndex: 'ethnicity', - width: 90, - render: (v: string, record: any) => ( - saveCell(record, 'ethnicity', next)} - > - {v || '-'} - - ), - }, - { - title: '紧急联系人', - dataIndex: 'emergencyContact', - width: 100, - render: (v: string, record: any) => ( - saveCell(record, 'emergencyContact', next)} - > - {v || '-'} - - ), - }, - { - title: '紧急联系人电话', - dataIndex: 'emergencyPhone', - width: 150, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskPhone(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); - }, - }, - { - title: '所属机构', - dataIndex: 'organization', - width: 100, - render: (organization: { name?: string } | null, record: any) => - canChooseOrganization ? ( - ({ value: item.id, label: item.name }))} - permission="student:edit" - disabled={record.status === 'archived'} - required - onSave={(next) => saveCell(record, 'organizationId', next)} - > - {organization?.name ? ( - - {organization.name} - - ) : ( - '-' - )} - - ) : organization?.name ? ( - {organization.name} - ) : ( - '-' - ), - }, - { - title: '负责人', - dataIndex: 'supervisor', - width: 100, - render: (v: string, record: any) => ( - saveCell(record, 'supervisor', next)} - > - {v || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, record: any) => ( - saveCell(record, 'status', next)} - > - - {statusMap[s]?.text || s} - - - ), - }, - { - title: '操作', - width: 180, - render: (_: any, record: any) => ( - - {record.status === 'archived' ? ( - canEditStudent ? ( - handleRestore(record.id)} - okText="恢复" - cancelText="取消" - > - - - ) : null - ) : ( - <> - openDrawer(record.id)} - > - 档案 - - { - setEditing(record); - form.setFieldsValue(record); - setModalOpen(true); - }} - > - 编辑 - - {canDeleteStudent ? ( - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - - - ) : null} - - )} - - ), - }, - ], + onRestore: handleRestore, + onPurge: handlePurge, + onArchive: handleArchive, + }), [ - handleViewSensitive, - openDrawer, - showArchived, - organizations, - saveCell, - hasPermission, - canChooseOrganization, pageInfo, + organizations, + canChooseOrganization, + canEditStudent, + canDeleteStudent, + canPurgeStudent, + hasPermission, + saveCell, + handleViewSensitive, + form, ], ); return (
-
- - - - {canViewOrganizations ? ( - - ) : null} - { - setFilterTeacherId(v); - }} - options={teacherOptions.map((item) => ({ - value: item.id, - label: item.name === item.username ? item.name : `${item.name}(${item.username})`, - }))} - /> - - - - {showArchived && canEditStudent ? ( - - - - ) : !showArchived && canDeleteStudent ? ( - - - - ) : null} - {!showArchived ? ( - } - onClick={() => { - setEditing(null); - form.resetFields(); - const host = organizations.find((organization) => organization.isHost); - if (host) form.setFieldValue('organizationId', host.id); - setModalOpen(true); - }} - > - 添加学生 - - ) : null} - {!showArchived && hasPermission('student:import') ? ( - <> - - - - - - - - ) : null} - {!showArchived && canSyncJinshuju ? ( - - ) : null} - {!showArchived && canSyncDingTalk ? ( - - ) : null} - } - onClick={handleDownloadTemplate} - > - 下载模板 - - } - onClick={handleExport} - > - 导出名单 - - -
- {selectedRowKeys.length > 0 ? ( - - 已选 {selectedRowKeys.length} 人(支持跨页勾选) - - } - action={ - - } - /> - ) : null} - - 更新已有学生资料:先按手机号、再按身份证号匹配;Excel - 中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。 - - } + { + setShowArchived(!showArchived); + setFilterStatus(undefined); + setSelectedRowKeys([]); + }} + selectedRowKeys={selectedRowKeys} + batchLoading={batchLoading} + canEditStudent={canEditStudent} + canPurgeStudent={canPurgeStudent} + canDeleteStudent={canDeleteStudent} + canSyncJinshuju={canSyncJinshuju} + canSyncDingTalk={canSyncDingTalk} + dingSyncLoading={dingSyncLoading} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onBatchDelete={handleBatchDelete} + onAddStudent={() => { + setEditing(null); + form.resetFields(); + const host = organizations.find((organization) => organization.isHost); + if (host) form.setFieldValue('organizationId', host.id); + setModalOpen(true); + }} + onOpenJinshuju={() => setJinshujuOpen(true)} + onDingTalkSync={handleDingTalkSync} + onCreateImport={handleCreateStudentsImport} + onUpdateImport={handleUpdateExistingStudentsImport} + onDownloadTemplate={handleDownloadTemplate} + onExport={handleExport} /> - }} - scroll={{ x: 1410 }} - pagination={{ - defaultPageSize: 15, - current: pageInfo.current, - pageSize: pageInfo.pageSize, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 人`, - onChange: (current, pageSize) => setPageInfo({ current, pageSize }), - }} - rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), - }} - expandable={{ - rowExpandable: () => true, - expandedRowRender: (record) => { - const enrollments = enrollmentData[record.id]; - if (!enrollments) return null; - if (enrollments.length < 2) { - return ( -
- 当前仅 {enrollments.length} 个班型,无可对比数据 -
- ); - } - return ( - - - {enrollments.map((enr, idx) => ( - - - - {enr.className || '-'} - - {enr.startDate || enr.joinDate || '-'} - - - {enr.endDate || enr.leaveDate || '-'} - - - - {enr.status || '-'} - - - - - - ))} - - - ); - }, - onExpand: async (expanded, record) => { - if (expanded && !enrollmentData[record.id]) { - try { - const res = await api.get<{ enrollments: EnrollmentInfo[] }>( - `/students/${record.id}/compare-classes`, - ); - setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments })); - } catch { - setEnrollmentData((prev) => ({ ...prev, [record.id]: [] })); - } - } - }, - }} + pageInfo={pageInfo} + onPageChange={(current, pageSize) => setPageInfo({ current, pageSize })} + selectedRowKeys={selectedRowKeys} + onSelect={setSelectedRowKeys} + onClearSelection={() => setSelectedRowKeys([])} /> - - { setModalOpen(false); setEditing(null); }} - okText="保存" - confirmLoading={saving} - > - - - - - - - - - - - - - - - - - - - - - - - {canChooseOrganization ? ( - - - - {editing && ( - - ({ label: type, value: type }))} /> 仅看欠费 - + { ]} /> + { > + @@ -331,6 +371,7 @@ const WalletsPage: React.FC = () => { ]} /> + { > + @@ -346,7 +388,7 @@ const WalletsPage: React.FC = () => { { setDrawerOpen(false); @@ -372,15 +414,15 @@ const WalletsPage: React.FC = () => { title: '金额', dataIndex: 'amount', render: (value: number) => ( - = 0 ? '#389e0d' : '#cf1322' }}> - {Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} + = 0 ? '#389e0d' : '#cf1322' }}> + {value >= 0 ? '+' : ''}¥{value.toFixed(2)} ), }, { title: '变动后余额', dataIndex: 'balanceAfter', - render: (value: number) => `¥${Number(value).toFixed(2)}`, + render: (value: number) => `¥${value.toFixed(2)}`, }, { title: '关联账单', diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index ba01a07..9d1690a 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -65,7 +65,7 @@ export class BillsExportService { const total = Number(bill.totalAmount || 0); ws.addRow({ id: bill.id, - studentName: (bill as any).student?.name || '-', + studentName: bill.student?.name || '-', period: `${bill.periodStart} ~ ${bill.periodEnd}`, shared: Number(bill.sharedAmount), personal: Number(bill.personalAmount), @@ -96,7 +96,7 @@ export class BillsExportService { for (const item of bill.items || []) { ws2.addRow({ billId: bill.id, - studentName: (bill as any).student?.name || '-', + studentName: bill.student?.name || '-', expenseType: item.expenseType, description: item.description, days: item.days, @@ -158,7 +158,9 @@ export class BillsExportService { fontRegistered = true; break; } - } catch {} + } catch { + // 字体注册失败时回退到默认字体 + } } if (!fontRegistered) { // 如果没有中文字体,使用 Helvetica(中文可能乱码) @@ -183,7 +185,7 @@ export class BillsExportService { // 基本信息 doc.fontSize(12).fillColor('#000'); - doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`); + doc.text(`学生姓名: ${bill.student?.name || '-'}`); doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`); doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`); doc.moveDown(0.5); diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts new file mode 100644 index 0000000..cfcf2cc --- /dev/null +++ b/apps/server/src/bills/bills-generation.service.ts @@ -0,0 +1,285 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; +import { WalletsService } from '../wallets/wallets.service'; +import type { GenerateBillsDto } from './dto/bill.dto'; + +@Injectable() +export class BillsGenerationService { + constructor( + @InjectRepository(Bill) private billRepo: Repository, + @InjectRepository(BillItem) private itemRepo: Repository, + @InjectRepository(RoomExpense) private roomExpRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpRepo: Repository, + @InjectRepository(Occupancy) private occRepo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + private dataSource: DataSource, + private walletsService: WalletsService, + ) {} + + async generateBillsOnce(dto: GenerateBillsDto) { + const { periodStart, periodEnd } = dto.billingMonth + ? this.resolveBillingPeriod(dto.billingMonth) + : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); + } + const pStart = new Date(`${periodStart}T00:00:00Z`); + const pEnd = new Date(`${periodEnd}T00:00:00Z`); + const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); + if (existingBills.length > 0) { + throw new BadRequestException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, + ); + } + const roomExpenses = await this.roomExpRepo + .createQueryBuilder('e') + .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { + periodStart, + periodEnd, + }) + .andWhere('e.status = :status', { status: 'active' }) + .getMany(); + const longTermOccupancies: Occupancy[] = []; + const roomExpMap = new Map(); + for (const expense of roomExpenses) { + const expenses = roomExpMap.get(expense.roomId) || []; + expenses.push(expense); + roomExpMap.set(expense.roomId, expenses); + } + const roomIds = new Set([ + ...roomExpMap.keys(), + ...longTermOccupancies + .filter((occupancy) => occupancy.stayType === 'long') + .map((occupancy) => occupancy.roomId), + ]); + const studentBillData = new Map< + number, + { shared: number; items: Array> } + >(); + + for (const roomId of roomIds) { + const expenses = roomExpMap.get(roomId) || []; + const occupancies = await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.roomId = :roomId', { roomId }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); + const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); + const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); + + for (const occupancy of longTermOccs) { + const rent = this.calculateLongTermRent( + occupancy, + periodStart, + periodEnd, + Number(occupancy.room?.monthlyRate || 0), + ); + if (rent <= 0) continue; + const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] }; + data.shared += rent; + data.items.push({ + roomId, + expenseType: 'rent', + description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`, + days: 0, + totalRoomDays: 0, + roomTotalAmount: rent, + studentAmount: rent, + }); + studentBillData.set(occupancy.studentId, data); + } + + const studentDays = shortTermOccs.map((occupancy) => { + const start = new Date( + Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()), + ); + const end = occupancy.billingEndDate + ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) + : pEnd; + const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); + return { studentId: occupancy.studentId, days }; + }); + const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); + if (totalDays === 0) continue; + + for (const expense of expenses) { + const eligibleDays = studentDays.filter((entry) => entry.days > 0); + const expenseTotal = Number(Number(expense.amount).toFixed(2)); + let allocated = 0; + for (const [index, entry] of eligibleDays.entries()) { + const amount = + index === eligibleDays.length - 1 + ? Number((expenseTotal - allocated).toFixed(2)) + : Number(((entry.days / totalDays) * expenseTotal).toFixed(2)); + allocated = Number((allocated + amount).toFixed(2)); + const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] }; + data.shared += amount; + data.items.push({ + roomExpenseId: expense.id, + roomId, + expenseType: expense.expenseType, + description: `${expense.expenseType} 分摊`, + days: entry.days, + totalRoomDays: totalDays, + roomTotalAmount: expense.amount, + studentAmount: amount, + }); + studentBillData.set(entry.studentId, data); + } + } + } + + const personalExps = await this.personalExpRepo + .createQueryBuilder('pe') + .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { + periodStart, + periodEnd, + }) + .andWhere('pe.status = :status', { status: 'active' }) + .andWhere('pe.billId IS NULL') + .getMany(); + const personalMap = new Map(); + const personalItems = new Map>>(); + for (const expense of personalExps) { + personalMap.set( + expense.studentId, + (personalMap.get(expense.studentId) || 0) + Number(expense.amount), + ); + const items = personalItems.get(expense.studentId) || []; + items.push({ + personalExpenseId: expense.id, + roomId: expense.roomId, + expenseType: expense.expenseType, + description: `个人费用: ${expense.description || expense.expenseType}`, + days: 0, + totalRoomDays: 0, + roomTotalAmount: expense.amount, + studentAmount: expense.amount, + }); + personalItems.set(expense.studentId, items); + } + + const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); + const bills = await this.dataSource.transaction(async (manager) => { + const generated: Bill[] = []; + for (const studentId of allStudentIds) { + const shared = studentBillData.get(studentId)?.shared || 0; + const personal = personalMap.get(studentId) || 0; + const total = Number((shared + personal).toFixed(2)); + let bill = await manager.save( + manager.create(Bill, { + studentId, + periodStart, + periodEnd, + sharedAmount: Number(shared.toFixed(2)), + personalAmount: personal, + totalAmount: total, + source: 'batch', + paidAmount: 0, + outstandingAmount: total, + status: 'unpaid', + }), + ); + const items = [ + ...(studentBillData.get(studentId)?.items || []), + ...(personalItems.get(studentId) || []), + ]; + for (const item of items) + await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); + const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); + if (includedPersonal.length) { + await manager + .createQueryBuilder() + .update(PersonalExpense) + .set({ billId: bill.id }) + .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) + .execute(); + } + bill = await this.walletsService.debitBill(manager, bill); + generated.push(bill); + } + return generated; + }); + return { + message: `成功生成 ${bills.length} 条账单`, + count: bills.length, + bills, + periodStart, + periodEnd, + }; + } + + + private calculateLongTermRent( + occupancy: Occupancy, + periodStart: string, + periodEnd: string, + monthlyRate: number, + ) { + const activeStart = + occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; + const activeEnd = + occupancy.billingEndDate && occupancy.billingEndDate < periodEnd + ? occupancy.billingEndDate + : periodEnd; + if (activeEnd < activeStart || monthlyRate <= 0) return 0; + const [startYear, startMonth] = activeStart.split('-').map(Number); + const [endYear, endMonth] = activeEnd.split('-').map(Number); + let total = 0; + for ( + let year = startYear, month = startMonth; + year < endYear || (year === endYear && month <= endMonth); + ) { + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const prefix = `${year}-${String(month).padStart(2, '0')}-`; + const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; + const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; + const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd; + const days = + Math.floor( + (Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / + 86_400_000, + ) + 1; + total += (monthlyRate * days) / daysInMonth; + if (++month > 12) { + month = 1; + year++; + } + } + return Number(total.toFixed(2)); + } + + + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + + + private resolveBillingPeriod(billingMonth: string) { + const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); + if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + const year = Number(matched[1]); + const month = Number(matched[2]); + if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + const targetMonthStart = new Date(year, month - 1, 1); + const currentMonthStart = new Date(); + currentMonthStart.setDate(1); + currentMonthStart.setHours(0, 0, 0, 0); + if (targetMonthStart >= currentMonthStart) + throw new BadRequestException('只能生成已结束月份的账单'); + const targetMonthEnd = new Date(year, month, 0); + const pad = (value: number) => String(value).padStart(2, '0'); + return { + periodStart: `${year}-${pad(month)}-01`, + periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`, + }; + } + +} diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index a9f4122..43085e1 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -24,7 +24,7 @@ import { BillsExportService } from './bills-export.service'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import type { Response } from 'express'; @@ -43,16 +43,9 @@ export class BillsController { @Post('generate') @RequirePermission('bill:generate') async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.generateBills(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '生成账单', - detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, }); // Send bill_generated notifications try { @@ -100,17 +93,9 @@ export class BillsController { @Body() dto: UpdateBillStatusDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateStatus(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '确认账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', }); // Send bill_paid notification try { @@ -130,16 +115,9 @@ export class BillsController { @Put('batch/status') @RequirePermission('bill:confirm') async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchUpdateStatus(body.ids, body.status); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '确认账单', - detail: `IDs: ${body.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, }); // Send bill_paid notifications (batch) try { @@ -163,17 +141,8 @@ export class BillsController { @RequirePermission('bill:delete') async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) { const result = await this.service.cancel(id, dto, req.user?.id); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '取消账单并冲正', - targetId: id, - targetType: 'bill', - detail: dto.reason, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, }); return result; } @@ -181,17 +150,29 @@ export class BillsController { @Delete(':id') @RequirePermission('bill:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '归档账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('bill:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('bill:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -199,16 +180,9 @@ export class BillsController { @Post('batch/delete') @RequirePermission('bill:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '批量归档账单', - detail: `IDs: ${body.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, }); return result; } @@ -223,15 +197,8 @@ export class BillsController { @Res() res?: Response, @Req() req?: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req?.user?.id, - username: req?.user?.username, - module: '账单管理', - action: '导出账单', - detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, }); return this.exportService.exportExcel( { @@ -247,16 +214,8 @@ export class BillsController { @Get('export/pdf/:id') @RequirePermission('bill:export-pdf') async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req?.user?.id, - username: req?.user?.username, - module: '账单管理', - action: '导出账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill', }); return this.exportService.exportStudentPdf(id, res); } diff --git a/apps/server/src/bills/bills.module.ts b/apps/server/src/bills/bills.module.ts index 0631108..c58469e 100644 --- a/apps/server/src/bills/bills.module.ts +++ b/apps/server/src/bills/bills.module.ts @@ -11,6 +11,7 @@ import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { BillsService } from './bills.service'; +import { BillsGenerationService } from './bills-generation.service'; import { BillsExportService } from './bills-export.service'; import { BillsController } from './bills.controller'; @@ -30,7 +31,7 @@ import { BillsController } from './bills.controller'; WalletsModule, ], controllers: [BillsController], - providers: [BillsService, BillsExportService], + providers: [BillsService, BillsExportService, BillsGenerationService], exports: [BillsService], }) export class BillsModule {} diff --git a/apps/server/src/bills/bills.purge.controller.spec.ts b/apps/server/src/bills/bills.purge.controller.spec.ts new file mode 100644 index 0000000..40fcea3 --- /dev/null +++ b/apps/server/src/bills/bills.purge.controller.spec.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { BillsController } from './bills.controller'; + +describe('BillsController purge routes', () => { + it('requires bill:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.purge)).toEqual([ + 'bill:purge', + ]); + expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.batchPurge)).toEqual([ + 'bill:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除账单(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new BillsController( + service as never, + {} as never, + { log } as never, + {} as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '账单管理', action: '永久删除账单', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/bills/bills.purge.spec.ts b/apps/server/src/bills/bills.purge.spec.ts new file mode 100644 index 0000000..e2e39f9 --- /dev/null +++ b/apps/server/src/bills/bills.purge.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { BillsService } from './bills.service'; + +describe('BillsService.purge', () => { + const createService = (overrides?: { bill?: Record }) => { + const bill = { + id: 1, + studentId: 2, + status: 'cancelled', + paidAmount: 0, + ...overrides?.bill, + }; + const billRepo = { + findOne: jest.fn().mockResolvedValue(bill), + find: jest.fn().mockResolvedValue([bill]), + }; + const personalExpRepo = { count: jest.fn().mockResolvedValue(0) }; + const manager = { + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const service = new BillsService( + billRepo as never, + {} as never, + {} as never, + personalExpRepo as never, + {} as never, + {} as never, + dataSource as never, + {} as never, + ); + return { service, billRepo, personalExpRepo, dataSource, manager }; + }; + + it('rejects bills that are not cancelled', async () => { + const { service, dataSource } = createService({ bill: { status: 'unpaid' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已取消账单可以永久删除,请先取消账单'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled bills with paid amount', async () => { + const { service, dataSource } = createService({ bill: { paidAmount: 100 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('已发生资金流水的账单不能永久删除'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled bills still referenced by personal expenses', async () => { + const { service, personalExpRepo, dataSource } = createService(); + personalExpRepo.count.mockResolvedValue(1); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该账单仍关联个人费用,无法永久删除'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('deletes bill items and bill in a transaction', async () => { + const { service, dataSource, manager } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除账单(不可恢复)', + }); + expect(dataSource.transaction).toHaveBeenCalled(); + expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { billId: 1 }); + expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1); + }); +}); diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 3c38bb7..435c2ab 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { BillsService } from './bills.service'; +import { BillsGenerationService } from './bills-generation.service'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -78,6 +79,7 @@ describe('BillsService — generateBills', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ BillsService, + BillsGenerationService, { provide: getRepositoryToken(Bill), useValue: billRepo }, { provide: getRepositoryToken(BillItem), useValue: itemRepo }, { provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo }, @@ -567,6 +569,17 @@ describe('BillsService — allocation rounding boundary', () => { })), })), }; + const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any; + const generation = new BillsGenerationService( + billRepo as any, + itemRepo as any, + roomExpRepo as any, + personalExpRepo as any, + occRepo as any, + roomRepo as any, + dataSource as any, + walletsService, + ); const service = new BillsService( billRepo as any, itemRepo as any, @@ -575,7 +588,8 @@ describe('BillsService — allocation rounding boundary', () => { occRepo as any, roomRepo as any, dataSource as any, - { debitBill: jest.fn(async (_manager, bill) => bill) } as any, + walletsService, + generation, ); (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder([ { id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense, diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 35754a8..57209aa 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, DataSource, EntityManager } from 'typeorm'; +import { Repository, In, DataSource } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -11,6 +11,7 @@ import { StudentWallet } from '../entities/student-wallet.entity'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { WalletsService } from '../wallets/wallets.service'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; +import { BillsGenerationService } from './bills-generation.service'; interface AgentBillRow { billId: string | number; @@ -23,7 +24,6 @@ interface AgentBillRow { status: string; } - @Injectable() export class BillsService { constructor( @@ -35,6 +35,7 @@ export class BillsService { @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, + private generation: BillsGenerationService, @Optional() private financialOperations?: FinancialOperationsService, ) {} @@ -44,220 +45,12 @@ export class BillsService { */ async generateBills(dto: GenerateBillsDto) { const { operationId, ...request } = dto; - const work = () => this.generateBillsOnce(request as GenerateBillsDto); + const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto); return this.financialOperations ? this.financialOperations.run(operationId, 'bill.generate', work) : work(); } - private async generateBillsOnce(dto: GenerateBillsDto) { - const { periodStart, periodEnd } = dto.billingMonth - ? this.resolveBillingPeriod(dto.billingMonth) - : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; - if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { - throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); - } - const pStart = new Date(`${periodStart}T00:00:00Z`); - const pEnd = new Date(`${periodEnd}T00:00:00Z`); - const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); - if (existingBills.length > 0) { - throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`); - } - const roomExpenses = await this.roomExpRepo - .createQueryBuilder('e') - .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd }) - .andWhere('e.status = :status', { status: 'active' }) - .getMany(); - const longTermOccupancies: Occupancy[] = []; - const roomExpMap = new Map(); - for (const expense of roomExpenses) { - const expenses = roomExpMap.get(expense.roomId) || []; - expenses.push(expense); - roomExpMap.set(expense.roomId, expenses); - } - const roomIds = new Set([ - ...roomExpMap.keys(), - ...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId), - ]); - const studentBillData = new Map> }>(); - - for (const roomId of roomIds) { - const expenses = roomExpMap.get(roomId) || []; - const occupancies = await this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); - const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); - const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); - - for (const occupancy of longTermOccs) { - const rent = this.calculateLongTermRent( - occupancy, - periodStart, - periodEnd, - Number(occupancy.room?.monthlyRate || 0), - ); - if (rent <= 0) continue; - const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] }; - data.shared += rent; - data.items.push({ - roomId, - expenseType: 'rent', - description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`, - days: 0, - totalRoomDays: 0, - roomTotalAmount: rent, - studentAmount: rent, - }); - studentBillData.set(occupancy.studentId, data); - } - - const studentDays = shortTermOccs.map((occupancy) => { - const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime())); - const end = occupancy.billingEndDate - ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) - : pEnd; - const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); - return { studentId: occupancy.studentId, days }; - }); - const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); - if (totalDays === 0) continue; - - for (const expense of expenses) { - const eligibleDays = studentDays.filter((entry) => entry.days > 0); - const expenseTotal = Number(Number(expense.amount).toFixed(2)); - let allocated = 0; - for (const [index, entry] of eligibleDays.entries()) { - const amount = index === eligibleDays.length - 1 - ? Number((expenseTotal - allocated).toFixed(2)) - : Number(((entry.days / totalDays) * expenseTotal).toFixed(2)); - allocated = Number((allocated + amount).toFixed(2)); - const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] }; - data.shared += amount; - data.items.push({ - roomExpenseId: expense.id, - roomId, - expenseType: expense.expenseType, - description: `${expense.expenseType} 分摊`, - days: entry.days, - totalRoomDays: totalDays, - roomTotalAmount: expense.amount, - studentAmount: amount, - }); - studentBillData.set(entry.studentId, data); - } - } - } - - const personalExps = await this.personalExpRepo - .createQueryBuilder('pe') - .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd }) - .andWhere('pe.status = :status', { status: 'active' }) - .andWhere('pe.billId IS NULL') - .getMany(); - const personalMap = new Map(); - const personalItems = new Map>>(); - for (const expense of personalExps) { - personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount)); - const items = personalItems.get(expense.studentId) || []; - items.push({ - personalExpenseId: expense.id, - roomId: expense.roomId, - expenseType: expense.expenseType, - description: `个人费用: ${expense.description || expense.expenseType}`, - days: 0, - totalRoomDays: 0, - roomTotalAmount: expense.amount, - studentAmount: expense.amount, - }); - personalItems.set(expense.studentId, items); - } - - const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); - const bills = await this.dataSource.transaction(async (manager) => { - const generated: Bill[] = []; - for (const studentId of allStudentIds) { - const shared = studentBillData.get(studentId)?.shared || 0; - const personal = personalMap.get(studentId) || 0; - const total = Number((shared + personal).toFixed(2)); - let bill = await manager.save(manager.create(Bill, { - studentId, - periodStart, - periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, - source: 'batch', - paidAmount: 0, - outstandingAmount: total, - status: 'unpaid', - })); - const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])]; - for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); - const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); - if (includedPersonal.length) { - await manager.createQueryBuilder() - .update(PersonalExpense) - .set({ billId: bill.id }) - .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) - .execute(); - } - bill = await this.walletsService.debitBill(manager, bill); - generated.push(bill); - } - return generated; - }); - return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; - } - - private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) { - const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; - const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd - ? occupancy.billingEndDate - : periodEnd; - if (activeEnd < activeStart || monthlyRate <= 0) return 0; - const [startYear, startMonth] = activeStart.split('-').map(Number); - const [endYear, endMonth] = activeEnd.split('-').map(Number); - let total = 0; - for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) { - const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); - const prefix = `${year}-${String(month).padStart(2, '0')}-`; - const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; - const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; - const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd; - const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1; - total += monthlyRate * days / daysInMonth; - if (++month > 12) { month = 1; year++; } - } - return Number(total.toFixed(2)); - } - - private isValidDate(value: string) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; - const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; - } - - private resolveBillingPeriod(billingMonth: string) { - const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); - if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); - const year = Number(matched[1]); - const month = Number(matched[2]); - if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); - const targetMonthStart = new Date(year, month - 1, 1); - const currentMonthStart = new Date(); - currentMonthStart.setDate(1); - currentMonthStart.setHours(0, 0, 0, 0); - if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单'); - const targetMonthEnd = new Date(year, month, 0); - const pad = (value: number) => String(value).padStart(2, '0'); - return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` }; - } - async createImmediatePersonalBill( expense: PersonalExpense, periodStart: string, @@ -286,7 +79,8 @@ export class BillsService { personalExpenseId: expense.id, roomId: expense.roomId, expenseType: expense.expenseType, - description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), + description: + expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), days: 0, totalRoomDays: 0, roomTotalAmount: expense.amount, @@ -323,19 +117,28 @@ export class BillsService { } async agentSearchBills(query: { - keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number; + keyword?: string; + periodStart?: string; + periodEnd?: string; + status?: string; + limit?: number; }) { + const billSelects = [ + ['student.name', 'studentName'], + ['bill.periodStart', 'periodStart'], + ['bill.periodEnd', 'periodEnd'], + ['bill.totalAmount', 'totalAmount'], + ['bill.paidAmount', 'paidAmount'], + ['bill.outstandingAmount', 'outstandingAmount'], + ['bill.status', 'status'], + ] as const; const qb = this.billRepo .createQueryBuilder('bill') .leftJoin('bill.student', 'student') - .select('bill.id', 'billId') - .addSelect('student.name', 'studentName') - .addSelect('bill.periodStart', 'periodStart') - .addSelect('bill.periodEnd', 'periodEnd') - .addSelect('bill.totalAmount', 'totalAmount') - .addSelect('bill.paidAmount', 'paidAmount') - .addSelect('bill.outstandingAmount', 'outstandingAmount') - .addSelect('bill.status', 'status'); + .select('bill.id', 'billId'); + for (const [column, alias] of billSelects) { + qb.addSelect(column, alias); + } if (query.keyword) { const billId = Number(query.keyword); if (Number.isInteger(billId) && billId > 0) { @@ -347,14 +150,21 @@ export class BillsService { qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` }); } } - if (query.periodStart) qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart }); - if (query.periodEnd) qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); + if (query.periodStart) + qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart }); + if (query.periodEnd) + qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); if (query.status) qb.andWhere('bill.status = :status', { status: query.status }); - const rows = await qb.orderBy('bill.generatedAt', 'DESC').limit(query.limit ?? 20).getRawMany(); + const rows = await qb + .orderBy('bill.generatedAt', 'DESC') + .limit(query.limit ?? 20) + .getRawMany(); return rows.map((row) => ({ ...row, - billId: Number(row.billId), totalAmount: Number(row.totalAmount || 0), - paidAmount: Number(row.paidAmount || 0), outstandingAmount: Number(row.outstandingAmount || 0), + billId: Number(row.billId), + totalAmount: Number(row.totalAmount || 0), + paidAmount: Number(row.paidAmount || 0), + outstandingAmount: Number(row.outstandingAmount || 0), })); } @@ -374,7 +184,9 @@ export class BillsService { .createQueryBuilder('wallet') .where('wallet.studentId IN (:...ids)', { ids: studentIds }) .getMany(); - const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)])); + const balanceMap = new Map( + wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]), + ); return bills.map((bill) => ({ ...bill, walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)), @@ -394,7 +206,8 @@ export class BillsService { async batchUpdateStatus(ids: number[], status: string) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单'); - if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效'); + if (!['unpaid', 'partially_paid', 'paid'].includes(status)) + throw new BadRequestException('账单状态无效'); const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); for (const bill of bills) this.assertStatusMatchesAmounts(bill, status); @@ -410,16 +223,18 @@ export class BillsService { async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { const reason = dto.reason?.trim(); if (!reason) throw new BadRequestException('取消原因不能为空'); - const work = () => this.dataSource.transaction(async (manager) => { - const bill = await manager.createQueryBuilder(Bill, 'bill') - .where('bill.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); - await manager.update(PersonalExpense, { billId: id }, { billId: null }); - return this.walletsService.refundBill(manager, bill, reason, recordedBy); - }); + const work = () => + this.dataSource.transaction(async (manager) => { + const bill = await manager + .createQueryBuilder(Bill, 'bill') + .where('bill.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); + await manager.update(PersonalExpense, { billId: id }, { billId: null }); + return this.walletsService.refundBill(manager, bill, reason, recordedBy); + }); return this.financialOperations ? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work) : work(); @@ -441,6 +256,65 @@ export class BillsService { return { message: '账单已归档' }; } + async purge(id: number) { + const bill = await this.billRepo.findOne({ where: { id } }); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status !== 'cancelled') { + throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单'); + } + if (Number(bill.paidAmount) > 0) { + throw new BadRequestException('已发生资金流水的账单不能永久删除'); + } + const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } }); + if (personalExpenseCount > 0) { + throw new BadRequestException('该账单仍关联个人费用,无法永久删除'); + } + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: id }); + await manager.delete(Bill, id); + }); + return { message: '已永久删除账单(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('账单 ID 无效'); + } + const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); + if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); + const personalExpenseCount = await this.personalExpRepo.count({ + where: { billId: In(uniqueIds) }, + }); + if (personalExpenseCount > 0) { + throw new BadRequestException('选中账单仍关联个人费用,无法永久删除'); + } + + const deleted: number[] = []; + const skipped: string[] = []; + for (const bill of bills) { + if (bill.status !== 'cancelled') { + skipped.push(`账单${bill.id}(未取消)`); + continue; + } + if (Number(bill.paidAmount) > 0) { + skipped.push(`账单${bill.id}(已支付)`); + continue; + } + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: bill.id }); + await manager.delete(Bill, bill.id); + }); + deleted.push(bill.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条账单;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条账单(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchRemove(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单'); @@ -469,11 +343,12 @@ export class BillsService { private assertStatusMatchesAmounts(bill: Bill, status: string) { const paid = Number(bill.paidAmount || 0); const outstanding = Number(bill.outstandingAmount || 0); - const matches = status === 'paid' - ? outstanding <= 0 - : status === 'partially_paid' - ? paid > 0 && outstanding > 0 - : status === 'unpaid' && paid <= 0 && outstanding > 0; + const matches = + status === 'paid' + ? outstanding <= 0 + : status === 'partially_paid' + ? paid > 0 && outstanding > 0 + : status === 'unpaid' && paid <= 0 && outstanding > 0; if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致'); } } diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts new file mode 100644 index 0000000..7e05d60 --- /dev/null +++ b/apps/server/src/classes/classes-queries.service.ts @@ -0,0 +1,172 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository, In } from 'typeorm'; +import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities'; +import { Classroom } from '../entities/classroom.entity'; +import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; +import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; + +interface AgentClassRow { + id: string | number; + name: string; + code: string; + studentCount: string | number; +} + +@Injectable() +export class ClassesQueriesService { + constructor( + @InjectRepository(Class) private readonly classRepo: Repository, + @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, + @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, + @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async agentSearchClasses( + accessibleClassIds: number[] | undefined, + query: { keyword?: string; status?: string; limit?: number }, + ) { + if (accessibleClassIds?.length === 0) return []; + + const qb = this.classRepo + .createQueryBuilder('class') + .leftJoin( + ClassStudent, + 'classStudent', + 'classStudent.classId = class.id AND classStudent.status = :activeStudent', + { activeStudent: 'active' }, + ) + .select('class.id', 'id'); + const classSelects = [ + ['class.name', 'name'], + ['class.code', 'code'], + ['class.classType', 'classType'], + ['class.status', 'status'], + ['class.startDate', 'startDate'], + ['class.endDate', 'endDate'], + ['COUNT(classStudent.id)', 'studentCount'], + ] as const; + for (const [column, alias] of classSelects) { + qb.addSelect(column, alias); + } + qb.where('class.isArchived = :isArchived', { isArchived: false }); + if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); + if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); + if (query.status) qb.andWhere('class.status = :status', { status: query.status }); + const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); + return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); + } + + async batchImportStudents( + classId: number, + users: Array<{ dingUserId: string; name: string; mobile?: string }>, + ): Promise<{ imported: number; skipped: number; conflicts: number }> { + if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; + + return this.dataSource.transaction(async (manager) => { + const classEntity = await manager.findOne(Class, { where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); + + const synced = await syncDingTalkStudents(manager, users); + const studentIds = [...new Set(synced.studentIds.values())]; + if (studentIds.length === 0) { + return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; + } + + const existingClassStudents = await manager.find(ClassStudent, { + where: { classId, studentId: In(studentIds) }, + }); + const existingByStudentId = new Map( + existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), + ); + const today = new Date().toISOString().slice(0, 10); + let skipped = 0; + const memberships = studentIds.flatMap((studentId) => { + const existing = existingByStudentId.get(studentId); + if (existing?.status === 'active') { + skipped++; + return []; + } + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ + manager.create(ClassStudent, { + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); + + if (memberships.length > 0) await manager.save(ClassStudent, memberships); + return { + imported: memberships.length, + skipped, + conflicts: synced.conflicts.length, + }; + }); +} + + async getSchedule(classId: number, query: QueryClassScheduleDto) { + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoinAndSelect('cs.classroom', 'classroom') + .where('cs.classId = :classId', { classId }); + + if (query.startDate) { + qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); + } + + const schedules = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .getMany(); + + return schedules.map((s) => ({ + ...s, + classroomName: (s.classroom as Classroom | undefined)?.name || null, + })); +} + + async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { + const qb = this.attendanceRepo + .createQueryBuilder('ar') + .where('ar.classId = :classId', { classId }); + + if (query.startDate) { + qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); + } + + const rows = await qb.getMany(); + + const total = rows.length; + const present = rows.filter((r) => r.status === 'present').length; + const late = rows.filter((r) => r.status === 'late').length; + const absent = rows.filter((r) => r.status === 'absent').length; + const leave = rows.filter((r) => r.status === 'leave').length; + + return { + total, + present, + late, + absent, + leave, + presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, + absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, + lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, + leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, + }; + } +} diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts index 6555445..0721423 100644 --- a/apps/server/src/classes/classes.batch-import-membership.spec.ts +++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts @@ -1,4 +1,5 @@ import { ClassesService } from './classes.service'; +import { ClassesQueriesService } from './classes-queries.service'; import { ClassStudent, Student, StudentDingMapping } from '../entities'; describe('ClassesService — DingTalk class import membership lifecycle', () => { @@ -32,6 +33,14 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => create: jest.fn().mockImplementation((_entity: unknown, value: object) => value), save: jest.fn().mockImplementation(async (_entity: unknown, value: unknown) => value), }; + const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); const service = new ClassesService( {} as never, {} as never, @@ -41,7 +50,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => {} as never, {} as never, {} as never, - { transaction: jest.fn().mockImplementation((work) => work(manager)) } as never, + dataSource as never, + {} as never, + queries, ); const result = await service.batchImportStudents(3, [ diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts index 102a40d..751dd4a 100644 --- a/apps/server/src/classes/classes.controller.spec.ts +++ b/apps/server/src/classes/classes.controller.spec.ts @@ -1,3 +1,5 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; import { ValidationPipe } from '@nestjs/common'; import { ClassesController } from './classes.controller'; import { ClassesService } from './classes.service'; @@ -114,3 +116,25 @@ describe('QueryClassDto - query transformation', () => { ).resolves.toEqual({ isArchived: expected }); }); }); + +describe('ClassesController purge route', () => { + it('requires class:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.purge)).toEqual([ + 'class:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '班级管理', action: '永久删除班级', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 547528b..2723aca 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -27,7 +27,7 @@ import { } from './dto/class.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; @@ -115,18 +115,9 @@ export class ClassesController { @Post() @RequirePermission('class:create') async create(@Body() dto: CreateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '创建班级', - targetId: result.id, - targetType: 'class', - detail: `班级${result.code} ${result.name}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`, }); return result; } @@ -155,18 +146,9 @@ export class ClassesController { @Put(':id') @RequirePermission('class:edit') async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '编辑班级', - targetId: +id, - targetType: 'class', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), }); return result; } @@ -174,17 +156,19 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '归档班级', - targetId: +id, - targetType: 'class', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('class:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', }); return result; } @@ -242,18 +226,9 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addStudents(+id, dto.studentIds); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加学生', - targetId: +id, - targetType: 'class', - detail: `新增${result.added}名学生`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, }); try { const cls = await this.service.findOne(+id); @@ -265,7 +240,9 @@ export class ClassesController { content: `班级新增${result.added}名学生`, }); } - } catch {} + } catch { + // 通知失败不影响班级新增结果 + } return result; } @@ -276,18 +253,9 @@ export class ClassesController { @Param('studentId') studentId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeStudent(+id, +studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除学生', - targetId: +id, - targetType: 'class', - detail: `移除学生${studentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, }); return result; } @@ -302,18 +270,9 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addTeacher(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加教师', - targetId: +id, - targetType: 'class', - detail: `教师${dto.userId} 角色${dto.roleType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, }); try { void this.notificationsService.create({ @@ -322,7 +281,9 @@ export class ClassesController { title: '班级分配', content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`, }); - } catch {} + } catch { + // 通知失败不影响班级分配结果 + } return result; } @@ -333,18 +294,9 @@ export class ClassesController { @Param('assignmentId') assignmentId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeTeacherAssignment(+id, +assignmentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师角色', - targetId: +id, - targetType: 'class', - detail: `移除教师分配${assignmentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, }); return result; } @@ -356,18 +308,9 @@ export class ClassesController { @Param('userId') userId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeTeacher(+id, +userId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师', - targetId: +id, - targetType: 'class', - detail: `移除教师${userId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, }); return result; } diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index 5a6f951..856c2e3 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -1,15 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities'; +import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities'; import { ClassesService } from './classes.service'; +import { ClassesQueriesService } from './classes-queries.service'; import { ClassesController } from './classes.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule], controllers: [ClassesController], - providers: [ClassesService], + providers: [ClassesService, ClassesQueriesService], exports: [ClassesService], }) export class ClassesModule {} diff --git a/apps/server/src/classes/classes.purge.spec.ts b/apps/server/src/classes/classes.purge.spec.ts new file mode 100644 index 0000000..205b047 --- /dev/null +++ b/apps/server/src/classes/classes.purge.spec.ts @@ -0,0 +1,54 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassesService } from './classes.service'; + +describe('ClassesService.purge', () => { + const createService = (overrides?: { + cls?: Record; + counts?: Record; + }) => { + const cls = { id: 1, name: '冲刺班', code: 'C1', isArchived: true, ...overrides?.cls }; + const repo = { + findOne: jest.fn().mockResolvedValue(cls), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const service = new ClassesService( + repo as never, + { count: countFor('classStudent') } as never, + { count: countFor('classTeacher') } as never, + { count: countFor('schedule') } as never, + { count: countFor('attendance') } as never, + { count: countFor('session') } as never, + {} as never, + {} as never, + {} as never, + { count: countFor('exam') } as never, + ); + return { service, repo }; + }; + + it('rejects classes that are not archived', async () => { + const { service, repo } = createService({ cls: { isArchived: false } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档班级可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects classes with students, teachers, schedules, exams, or attendance', async () => { + const { service, repo } = createService({ counts: { classStudent: 1 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该班级存在关联数据(班级学生),无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived class with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除班级(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 8c3f931..42f3409 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -1,23 +1,26 @@ import { - Injectable, - NotFoundException, - BadRequestException, - ForbiddenException, +Injectable, +NotFoundException, +BadRequestException, +ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository, In, Like } from 'typeorm'; +import { DataSource, +Repository, +In, +Like } from 'typeorm'; import { Class, - ClassStudent, - ClassTeacher, - ClassSchedule, - AttendanceRecord, - AttendanceSession, - Classroom, - Student, - StudentDingMapping, +ClassStudent, +ClassTeacher, +ClassSchedule, +AttendanceRecord, +AttendanceSession, +Exam, +Student, +StudentDingMapping } from '../entities'; -import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; +import { ClassesQueriesService } from './classes-queries.service'; import { normalizeDateOnly } from '../database/date-normalization'; import { CreateClassDto, @@ -33,17 +36,6 @@ interface RawStudentCount { count: string; } -interface AgentClassRow { - id: string | number; - name: string; - code: string; - classType: string; - status: string; - startDate: string | null; - endDate: string | null; - studentCount: string | number; -} - @Injectable() export class ClassesService { constructor( @@ -64,6 +56,9 @@ export class ClassesService { @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, private dataSource: DataSource, + @InjectRepository(Exam) + private examRepo: Repository, + private queries: ClassesQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -84,30 +79,22 @@ export class ClassesService { query: { keyword?: string; status?: string; limit?: number }, ) { const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); - if (accessibleClassIds?.length === 0) return []; + return this.queries.agentSearchClasses(accessibleClassIds, query); + } - const qb = this.classRepo - .createQueryBuilder('class') - .leftJoin( - ClassStudent, - 'classStudent', - 'classStudent.classId = class.id AND classStudent.status = :activeStudent', - { activeStudent: 'active' }, - ) - .select('class.id', 'id') - .addSelect('class.name', 'name') - .addSelect('class.code', 'code') - .addSelect('class.classType', 'classType') - .addSelect('class.status', 'status') - .addSelect('class.startDate', 'startDate') - .addSelect('class.endDate', 'endDate') - .addSelect('COUNT(classStudent.id)', 'studentCount') - .where('class.isArchived = :isArchived', { isArchived: false }); - if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); - if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); - if (query.status) qb.andWhere('class.status = :status', { status: query.status }); - const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); - return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); + async batchImportStudents( + classId: number, + users: Array<{ dingUserId: string; name: string; mobile?: string }>, + ): Promise<{ imported: number; skipped: number; conflicts: number }> { + return this.queries.batchImportStudents(classId, users); + } + + async getSchedule(classId: number, query: QueryClassScheduleDto) { + return this.queries.getSchedule(classId, query); + } + + async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { + return this.queries.getAttendanceSummary(classId, query); } async findAll(query: QueryClassDto, accessibleClassIds?: number[]) { @@ -227,64 +214,6 @@ export class ClassesService { return this.findOne(saved.id); } - async batchImportStudents( - classId: number, - users: Array<{ - dingUserId: string; - name: string; - mobile?: string; - }>, - ): Promise<{ imported: number; skipped: number; conflicts: number }> { - if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; - - return this.dataSource.transaction(async (manager) => { - const classEntity = await manager.findOne(Class, { where: { id: classId } }); - if (!classEntity) throw new NotFoundException('班级不存在'); - - const synced = await syncDingTalkStudents(manager, users); - const studentIds = [...new Set(synced.studentIds.values())]; - if (studentIds.length === 0) { - return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; - } - - const existingClassStudents = await manager.find(ClassStudent, { - where: { classId, studentId: In(studentIds) }, - }); - const existingByStudentId = new Map( - existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), - ); - const today = new Date().toISOString().slice(0, 10); - let skipped = 0; - const memberships = studentIds.flatMap((studentId) => { - const existing = existingByStudentId.get(studentId); - if (existing?.status === 'active') { - skipped++; - return []; - } - if (existing) { - existing.status = 'active'; - existing.joinDate = today; - existing.leaveDate = null; - return [existing]; - } - return [ - manager.create(ClassStudent, { - classId, - studentId, - status: 'active', - joinDate: today, - }), - ]; - }); - - if (memberships.length > 0) await manager.save(ClassStudent, memberships); - return { - imported: memberships.length, - skipped, - conflicts: synced.conflicts.length, - }; - }); - } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); @@ -323,6 +252,33 @@ export class ClassesService { return this.archive(id); } + /** 永久删除班级(仅已归档) */ + async purge(id: number) { + const cls = await this.classRepo.findOne({ where: { id } }); + if (!cls) throw new NotFoundException('班级不存在'); + if (!cls.isArchived) throw new BadRequestException('仅已归档班级可以永久删除,请先归档'); + const [studentCount, teacherCount, scheduleCount, examCount, sessionCount, attendanceCount] = + await Promise.all([ + this.classStudentRepo.count({ where: { classId: id } }), + this.classTeacherRepo.count({ where: { classId: id } }), + this.scheduleRepo.count({ where: { classId: id } }), + this.examRepo.count({ where: { classId: id } }), + this.attendanceSessionRepo.count({ where: { classId: id } }), + this.attendanceRepo.count({ where: { classId: id } }), + ]); + const references: string[] = []; + if (studentCount > 0) references.push('班级学生'); + if (teacherCount > 0) references.push('任课教师'); + if (scheduleCount > 0) references.push('排课'); + if (examCount > 0) references.push('考试'); + if (sessionCount > 0 || attendanceCount > 0) references.push('考勤记录'); + if (references.length > 0) { + throw new BadRequestException(`该班级存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.classRepo.delete(id); + return { message: '已永久删除班级(不可恢复)' }; + } + async getStudents(classId: number) { return this.classStudentRepo.find({ where: { classId }, @@ -447,61 +403,4 @@ export class ClassesService { academicTeacherId: academic?.userId ?? null, } as Partial); } - - async getSchedule(classId: number, query: QueryClassScheduleDto) { - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .leftJoinAndSelect('cs.classroom', 'classroom') - .where('cs.classId = :classId', { classId }); - - if (query.startDate) { - qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); - } - - const schedules = await qb - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .getMany(); - - return schedules.map((s) => ({ - ...s, - classroomName: (s.classroom as Classroom | undefined)?.name || null, - })); - } - - async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { - const qb = this.attendanceRepo - .createQueryBuilder('ar') - .where('ar.classId = :classId', { classId }); - - if (query.startDate) { - qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); - } - - const rows = await qb.getMany(); - - const total = rows.length; - const present = rows.filter((r) => r.status === 'present').length; - const late = rows.filter((r) => r.status === 'late').length; - const absent = rows.filter((r) => r.status === 'absent').length; - const leave = rows.filter((r) => r.status === 'leave').length; - - return { - total, - present, - late, - absent, - leave, - presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, - absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, - lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, - leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, - }; - } } diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index 5092f60..dd9e970 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -21,7 +21,7 @@ import { ClassroomRentalsService } from './classroom-rentals.service'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @UseGuards(JwtAuthGuard) @@ -102,18 +102,9 @@ export class ClassroomRentalsController { @Post() @RequirePermission('rental:create') async create(@Body() dto: CreateRentalDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '新增租赁', - targetId: result.id, - targetType: 'classroom-rental', - detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, }); return result; } @@ -121,18 +112,9 @@ export class ClassroomRentalsController { @Put(':id') @RequirePermission('rental:edit') async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '编辑租赁', - targetId: +id, - targetType: 'classroom-rental', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto), }); return result; } @@ -140,17 +122,9 @@ export class ClassroomRentalsController { @Put(':id/cancel') @RequirePermission('rental:edit') async cancel(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.cancel(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '取消租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental', }); return result; } @@ -158,17 +132,9 @@ export class ClassroomRentalsController { @Put(':id/end') @RequirePermission('rental:edit') async end(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.end(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '结束租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental', }); return result; } @@ -176,17 +142,19 @@ export class ClassroomRentalsController { @Delete(':id') @RequirePermission('rental:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '归档租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('rental:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复', }); return result; } @@ -211,18 +179,9 @@ export class ClassroomRentalsController { @Request() req: any, ) { if (!file) throw new BadRequestException('请上传合同文件'); - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.attachContract(+id, file); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '上传合同', - targetId: +id, - targetType: 'classroom-rental', - detail: file.originalname, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname, }); return result; } @@ -243,17 +202,9 @@ export class ClassroomRentalsController { @Delete(':id/contract') @RequirePermission('rental:edit') async deleteContract(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeContract(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '移除合同', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental', }); return result; } diff --git a/apps/server/src/classroom-rentals/classroom-rentals.module.ts b/apps/server/src/classroom-rentals/classroom-rentals.module.ts index 4b3cc5a..05aa9e4 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.module.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.module.ts @@ -4,17 +4,27 @@ import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; import { ClassroomRentalsController } from './classroom-rentals.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]), + TypeOrmModule.forFeature([ + ClassroomRental, + Classroom, + Organization, + ClassSchedule, + AttendanceRecord, + AttendanceSession, + ]), OperationLogsModule, ], controllers: [ClassroomRentalsController], - providers: [ClassroomRentalsService], + providers: [ClassroomRentalsService, RentalScheduleService], exports: [ClassroomRentalsService], }) export class ClassroomRentalsModule {} diff --git a/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts new file mode 100644 index 0000000..c407e64 --- /dev/null +++ b/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ClassroomRentalsController } from './classroom-rentals.controller'; + +describe('ClassroomRentalsController purge route', () => { + it('requires rental:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomRentalsController.prototype.purge)).toEqual([ + 'rental:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除租赁订单(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassroomRentalsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '教室租赁', action: '永久删除租赁订单', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts new file mode 100644 index 0000000..5ceb294 --- /dev/null +++ b/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts @@ -0,0 +1,92 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; + +describe('ClassroomRentalsService.purge', () => { + const createService = (overrides?: { + rental?: Record; + schedules?: Record[]; + sessionCount?: number; + recordCount?: number; + }) => { + const rental = { + id: 1, + classroomId: 2, + status: 'cancelled', + startDate: '2026-01-01', + endDate: '2026-01-31', + contractPath: null, + ...overrides?.rental, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(rental), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const scheduleRepo = { + find: jest.fn().mockResolvedValue(overrides?.schedules ?? []), + }; + const attendanceRepo = { count: jest.fn().mockResolvedValue(overrides?.recordCount ?? 0) }; + const attendanceSessionRepo = { + count: jest.fn().mockResolvedValue(overrides?.sessionCount ?? 0), + }; + const manager = { + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const scheduleService = new RentalScheduleService(repo as never, {} as never, scheduleRepo as never); + const service = new ClassroomRentalsService( + repo as never, + {} as never, + {} as never, + scheduleRepo as never, + attendanceRepo as never, + attendanceSessionRepo as never, + dataSource as never, + scheduleService, + ); + return { service, repo, scheduleRepo, attendanceRepo, attendanceSessionRepo, dataSource, manager }; + }; + + it('rejects rentals that are not cancelled', async () => { + const { service, dataSource } = createService({ rental: { status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled rentals whose schedules have attendance history', async () => { + const withSession = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + sessionCount: 1, + }); + await expect(withSession.service.purge(1)).rejects.toThrow( + new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'), + ); + + const withRecord = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + recordCount: 1, + }); + await expect(withRecord.service.purge(1)).rejects.toThrow( + new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'), + ); + expect(withRecord.dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('deletes schedules and rental without attendance history', async () => { + const { service, dataSource, manager } = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + }); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除租赁订单(不可恢复)', + }); + expect(dataSource.transaction).toHaveBeenCalled(); + expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { + id: expect.anything(), + }); + expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index 1378587..d35abc3 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -1,12 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConflictException } from '@nestjs/common'; -import { Not, Repository } from 'typeorm'; +import { DataSource, Not, Repository } from 'typeorm'; import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; function mockQueryBuilder(results: T[] = []) { @@ -28,6 +31,8 @@ describe('ClassroomRentalsService — findConflicts', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() }, @@ -35,6 +40,12 @@ describe('ClassroomRentalsService — findConflicts', () => { { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -131,10 +142,17 @@ describe('ClassroomRentalsService — unavailable dates', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } }, { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -225,10 +243,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo }, { provide: getRepositoryToken(Classroom), useValue: classroomRepo }, { provide: getRepositoryToken(Organization), useValue: organizationRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -475,11 +500,16 @@ describe('ClassroomRentalsService — organization roles', () => { createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), } as any; + const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo); const service = new ClassroomRentalsService( rentalRepo, classroomRepo, organizationRepo, scheduleRepo, + {} as any, + {} as any, + {} as any, + scheduleService, ); await service.create({ diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 93add92..94e8d0b 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -4,30 +4,35 @@ import { BadRequestException, ConflictException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; +import { RentalScheduleService } from './rental-schedule.service'; import * as path from 'path'; import * as fs from 'fs'; // 预设色板(与 organizations.service 保持一致,作为颜色兜底) -const COLOR_PALETTE = [ - '#ff7875', - '#ffa940', - '#ffc53d', - '#73d13d', - '#36cfc9', - '#40a9ff', - '#597ef7', - '#9254de', - '#f759ab', - '#8c8c8c', -]; +function rentalConflictError( + message: string, + conflicts: Array<{ id: number; startDate: string; endDate: string; lesseeOrganization?: { name?: string | null } | null }>, +) { + return new ConflictException({ + message, + conflicts: conflicts.map((c) => ({ + id: c.id, + startDate: c.startDate, + endDate: c.endDate, + organizationName: c.lesseeOrganization?.name, + })), + }); +} @Injectable() export class ClassroomRentalsService { @@ -36,6 +41,10 @@ export class ClassroomRentalsService { @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, + @InjectDataSource() private dataSource: DataSource, + private schedule: RentalScheduleService, ) {} get uploadDir(): string { @@ -74,7 +83,7 @@ export class ClassroomRentalsService { qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }); } const rentals = await qb.getMany(); - return rentals.map((rental) => this.withEffectiveStatus(rental)); + return rentals.map((rental) => this.schedule.withEffectiveStatus(rental)); } /** @@ -98,19 +107,24 @@ export class ClassroomRentalsService { contractName: string | null; }[] > { + const rentalSelects = [ + ['classroom.name', 'classroomName'], + ['lesseeOrganization.name', 'lesseeOrganizationName'], + ['r.startDate', 'startDate'], + ['r.endDate', 'endDate'], + ['r.dailyRate', 'dailyRate'], + ['r.totalAmount', 'totalAmount'], + ['r.status', 'status'], + ['r.contractOriginalName', 'contractName'], + ] as const; const qb = this.repo .createQueryBuilder('r') .leftJoin('r.classroom', 'classroom') .leftJoin('r.lesseeOrganization', 'lesseeOrganization') - .select('r.id', 'id') - .addSelect('classroom.name', 'classroomName') - .addSelect('lesseeOrganization.name', 'lesseeOrganizationName') - .addSelect('r.startDate', 'startDate') - .addSelect('r.endDate', 'endDate') - .addSelect('r.dailyRate', 'dailyRate') - .addSelect('r.totalAmount', 'totalAmount') - .addSelect('r.status', 'status') - .addSelect('r.contractOriginalName', 'contractName'); + .select('r.id', 'id'); + for (const [column, alias] of rentalSelects) { + qb.addSelect(column, alias); + } if (query?.classroomId) { qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId }); } @@ -148,142 +162,19 @@ export class ClassroomRentalsService { relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'], }); if (!rental) throw new NotFoundException('租赁订单不存在'); - return this.withEffectiveStatus(rental); + return this.schedule.withEffectiveStatus(rental); } async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { - const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); - const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; - const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; - - const [rentals, schedules] = await Promise.all([ - this.repo.find({ - where: { - ...(excludeId ? { id: Not(excludeId) } : {}), - classroomId, - status: ClassroomRentalStatus.ACTIVE, - startDate: LessThanOrEqual(monthEnd), - endDate: MoreThanOrEqual(monthStart), - }, - }), - this.scheduleRepo.find({ - where: { - classroomId, - status: ClassroomRentalStatus.ACTIVE, - scheduleType: 'INTERNAL', - startDate: LessThanOrEqual(monthEnd), - endDate: MoreThanOrEqual(monthStart), - }, - }), - ]); - - const unavailableDates = new Set(); - for (const rental of rentals) { - this.addDateRange( - unavailableDates, - rental.startDate > monthStart ? rental.startDate : monthStart, - rental.endDate < monthEnd ? rental.endDate : monthEnd, - ); - } - for (const schedule of schedules) { - this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd); - } - - return { dates: Array.from(unavailableDates).sort() }; + return this.schedule.getUnavailableDates(classroomId, year, month, excludeId); } - /** - * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 - * 重叠判定:start1 <= end2 AND start2 <= end1 - */ async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) { - const qb = this.repo - .createQueryBuilder('r') - .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') - .where('r.classroomId = :cid', { cid: classroomId }) - .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) - .andWhere('r.startDate <= :end', { end: endDate }) - .andWhere('r.endDate >= :start', { start: startDate }); - if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); - const rentals = await qb.getMany(); - - // 检测同一教室同一日期段是否存在内部排课 - const scheduleCandidates = await this.scheduleRepo - .createQueryBuilder('cs') - .where('cs.classroomId = :cid', { cid: classroomId }) - .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) - .andWhere('cs.startDate <= :end', { end: endDate }) - .andWhere('cs.endDate >= :start', { start: startDate }) - .getMany(); - const scheduleConflicts = scheduleCandidates.filter((schedule) => - this.hasScheduleOccurrence(schedule, startDate, endDate), - ); - - if (scheduleConflicts.length > 0) { - throw new ConflictException({ - message: '该教室在此时间段已有排课', - conflicts: scheduleConflicts.map((s) => ({ - id: s.id, - startDate: s.startDate, - endDate: s.endDate, - organizationName: `[内部排课] ${s.subject}`, - })), - }); - } - - return rentals; + return this.schedule.findConflicts(classroomId, startDate, endDate, excludeId); } - private hasScheduleOccurrence( - schedule: ClassSchedule, - startDate: string, - endDate: string, - ): boolean { - const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; - const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; - if (overlapStart > overlapEnd) return false; - - const startUtc = this.toUtcDate(overlapStart); - const endUtc = this.toUtcDate(overlapEnd); - const startWeekDay = startUtc.getUTCDay() || 7; - const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; - startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); - return startUtc <= endUtc; - } - - private toUtcDate(date: string): Date { - const [year, month, day] = date.split('-').map(Number); - return new Date(Date.UTC(year, month - 1, day)); - } - - private addDateRange(dates: Set, startDate: string, endDate: string) { - const current = this.toUtcDate(startDate); - const end = this.toUtcDate(endDate); - while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); - current.setUTCDate(current.getUTCDate() + 1); - } - } - - private addScheduleOccurrences( - dates: Set, - schedule: ClassSchedule, - startDate: string, - endDate: string, - ) { - const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; - const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; - if (overlapStart > overlapEnd) return; - - const current = this.toUtcDate(overlapStart); - const end = this.toUtcDate(overlapEnd); - const startWeekDay = current.getUTCDay() || 7; - current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); - while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); - current.setUTCDate(current.getUTCDate() + 7); - } + async getSchedule(year: number, month: number) { + return this.schedule.getSchedule(year, month); } async create(dto: CreateRentalDto, userId?: number) { @@ -309,15 +200,7 @@ export class ClassroomRentalsService { const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate); if (conflicts.length > 0) { - throw new ConflictException({ - message: '该教室在此时间段已有租赁', - conflicts: conflicts.map((c) => ({ - id: c.id, - startDate: c.startDate, - endDate: c.endDate, - organizationName: c.lesseeOrganization?.name, - })), - }); + throw rentalConflictError('该教室在此时间段已有租赁', conflicts); } const rental = this.repo.create({ ...dto, @@ -327,7 +210,7 @@ export class ClassroomRentalsService { status: ClassroomRentalStatus.ACTIVE, }); const saved = await this.repo.save(rental); - await this.syncScheduleFromRental(saved, lesseeOrganization.name); + await this.schedule.syncScheduleFromRental(saved, lesseeOrganization.name); return saved; } @@ -351,15 +234,7 @@ export class ClassroomRentalsService { if (dto.classroomId || dto.startDate || dto.endDate) { const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id); if (conflicts.length > 0) { - throw new ConflictException({ - message: '修改后时间段与已有租赁冲突', - conflicts: conflicts.map((c) => ({ - id: c.id, - startDate: c.startDate, - endDate: c.endDate, - organizationName: c.lesseeOrganization?.name, - })), - }); + throw rentalConflictError('修改后时间段与已有租赁冲突', conflicts); } } const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId; @@ -381,7 +256,7 @@ export class ClassroomRentalsService { } await this.repo.update(id, dto); const updated = await this.findOne(id); - await this.syncScheduleFromRental(updated); + await this.schedule.syncScheduleFromRental(updated); return updated; } @@ -412,7 +287,7 @@ export class ClassroomRentalsService { endDate: rental.endDate > today ? today : rental.endDate, }); const ended = await this.findOne(id); - await this.syncScheduleFromRental(ended); + await this.schedule.syncScheduleFromRental(ended); return ended; } @@ -426,56 +301,40 @@ export class ClassroomRentalsService { return { message: '租赁订单已归档(合同文件已保留)' }; } - private withEffectiveStatus(rental: ClassroomRental) { - const today = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); - const effectiveStatus = - rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today - ? ClassroomRentalStatus.ENDED - : rental.status; - return Object.assign(rental, { effectiveStatus }); - } - - /** - * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') - */ - private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { - const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; - const weekDay = this.dateToWeekDay(rental.startDate); - let schedule = await this.scheduleRepo.findOne({ - where: { rentalId: rental.id, scheduleType: 'RENTAL' }, - }); - const data = { - classroomId: rental.classroomId, - classId: null, - weekDay, - startTime: '00:00', - endTime: '23:59', - startDate: rental.startDate, - endDate: rental.endDate, - subject: `${name} 租赁`, - teacherId: null, - scheduleType: 'RENTAL', - rentalId: rental.id, - status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', - notes: rental.notes, - }; - if (schedule) { - await this.scheduleRepo.update(schedule.id, data); - } else { - schedule = this.scheduleRepo.create(data); - await this.scheduleRepo.save(schedule); + async purge(id: number) { + const rental = await this.findOne(id); + if (rental.status !== ClassroomRentalStatus.CANCELLED) { + throw new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'); } - } - - private dateToWeekDay(date: string): number { - const d = new Date(date); - const day = d.getDay(); - return day === 0 ? 7 : day; + const schedules = await this.scheduleRepo.find({ + where: { rentalId: id, scheduleType: 'RENTAL' }, + }); + const scheduleIds = schedules.map((schedule) => schedule.id); + if (scheduleIds.length > 0) { + const [sessionCount, recordCount] = await Promise.all([ + this.attendanceSessionRepo.count({ where: { scheduleId: In(scheduleIds) } }), + this.attendanceRepo.count({ where: { scheduleId: In(scheduleIds) } }), + ]); + if (sessionCount > 0 || recordCount > 0) { + throw new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'); + } + } + await this.dataSource.transaction(async (manager) => { + if (scheduleIds.length > 0) { + await manager.delete(ClassSchedule, { id: In(scheduleIds) }); + } + await manager.delete(ClassroomRental, id); + }); + if (rental.contractPath) { + const fullPath = path.join(this.uploadDir, rental.contractPath); + try { + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } catch (error) { + // 文件删除失败仅告警,不阻塞数据库删除 + console.warn(`[ClassroomRentalsService] 合同文件删除失败: ${fullPath}`, error); + } + } + return { message: '已永久删除租赁订单(不可恢复)' }; } async attachContract(id: number, file: Express.Multer.File) { @@ -488,9 +347,7 @@ export class ClassroomRentalsService { const ext = path.extname(file.originalname).toLowerCase(); if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf'); // UUID 文件名 - const uuid = - (globalThis as any).crypto?.randomUUID?.() || - require('crypto').randomBytes(16).toString('hex'); + const uuid = require('crypto').randomBytes(16).toString('hex'); const filename = `${uuid}.pdf`; const fullPath = path.join(this.uploadDir, filename); // 路径遍历防护 @@ -525,7 +382,7 @@ export class ClassroomRentalsService { /* ignore */ } } - await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any }); + await this.repo.update(id, { contractPath: null, contractOriginalName: null }); return { message: '合同已移除' }; } @@ -544,127 +401,4 @@ export class ClassroomRentalsService { /** * 获取月度排期矩阵 */ - async getSchedule(year: number, month: number) { - const lastDay = new Date(year, month, 0).getDate(); - const first = `${year}-${String(month).padStart(2, '0')}-01`; - const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; - - const classrooms = await this.classroomRepo.find({ - where: { status: Not(ClassroomStatus.ARCHIVED) }, - order: { building: 'ASC', name: 'ASC' }, - }); - const rentals = await this.repo - .createQueryBuilder('r') - .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') - .leftJoinAndSelect('r.classroom', 'classroom') - .where('r.status IN (:...statuses)', { - statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], - }) - .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) - .getMany(); - - const organizationMap = new Map(); - const matrix: Record> = {}; - const summary: Record< - number, - { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number } - > = {}; - - for (const cls of classrooms) { - matrix[cls.id] = {}; - summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 }; - } - - for (const rental of rentals) { - const start = new Date(rental.startDate); - const end = new Date(rental.endDate); - const monthStart = new Date(first); - const monthEnd = new Date(last); - const effStart = start < monthStart ? monthStart : start; - const effEnd = end > monthEnd ? monthEnd : end; - if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { - organizationMap.set(rental.lesseeOrganization.id, { - id: rental.lesseeOrganization.id, - name: rental.lesseeOrganization.name, - color: - rental.lesseeOrganization.color || - COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], - }); - } - for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - const day = d.getDate(); - if (!matrix[rental.classroomId]) continue; - matrix[rental.classroomId][day] = { - scheduleType: 'RENTAL', - rentalId: rental.id, - organizationId: rental.lesseeOrganizationId, - organizationName: rental.lesseeOrganization?.name || '未知', - color: - rental.lesseeOrganization?.color || - COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], - hasContract: !!rental.contractPath, - }; - } - } - - // ── Overlay internal class schedules ── - const schedules = await this.scheduleRepo - .createQueryBuilder('s') - .leftJoinAndSelect('s.class', 'class') - .leftJoinAndSelect('s.teacher', 'teacher') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) - .getMany(); - - for (const sched of schedules) { - if (!sched.classroomId) continue; - const schedStart = new Date( - Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), - ); - const schedEnd = new Date( - Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), - ); - for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { - const dow = d.getDay() === 0 ? 7 : d.getDay(); - if (dow !== sched.weekDay) continue; - const day = d.getDate(); - if (!matrix[sched.classroomId]) continue; - matrix[sched.classroomId][day] = { - scheduleType: 'INTERNAL', - scheduleId: sched.id, - className: (sched.class as any)?.name || '', - subject: sched.subject, - teacherName: (sched.teacher as any)?.name || '', - startTime: sched.startTime, - endTime: sched.endTime, - color: '#52c41a', - }; - } - } - // 统计 - for (const cls of classrooms) { - const rented = Object.keys(matrix[cls.id]).length; - summary[cls.id].rentedDays = rented; - summary[cls.id].idleDays = lastDay - rented; - summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; - } - - return { - year, - month, - days: lastDay, - classrooms: classrooms.map((c) => ({ - id: c.id, - name: c.name, - building: c.building, - floor: c.floor, - roomType: c.roomType, - capacity: c.capacity, - })), - organizations: Array.from(organizationMap.values()), - matrix, - summary, - }; - } } diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts new file mode 100644 index 0000000..c1d6a2d --- /dev/null +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -0,0 +1,341 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities'; +import { ClassroomRentalStatus } from '../entities/classroom-rental.entity'; + +const COLOR_PALETTE = [ + "#5B8FF9", + "#61DDAA", + "#65789B", + "#F6BD16", + "#7262FD", + "#78D3F8", + "#9661BC", + "#F6903D", + "#008685", + "#F08BB4" +]; + +@Injectable() +export class RentalScheduleService { + constructor( + @InjectRepository(ClassroomRental) private repo: Repository, + @InjectRepository(Classroom) private classroomRepo: Repository, + @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + ) {} + + async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; + const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const [rentals, schedules] = await Promise.all([ + this.repo.find({ + where: { + ...(excludeId ? { id: Not(excludeId) } : {}), + classroomId, + status: ClassroomRentalStatus.ACTIVE, + startDate: LessThanOrEqual(monthEnd), + endDate: MoreThanOrEqual(monthStart), + }, + }), + this.scheduleRepo.find({ + where: { + classroomId, + status: ClassroomRentalStatus.ACTIVE, + scheduleType: 'INTERNAL', + startDate: LessThanOrEqual(monthEnd), + endDate: MoreThanOrEqual(monthStart), + }, + }), + ]); + + const unavailableDates = new Set(); + for (const rental of rentals) { + this.addDateRange( + unavailableDates, + rental.startDate > monthStart ? rental.startDate : monthStart, + rental.endDate < monthEnd ? rental.endDate : monthEnd, + ); + } + for (const schedule of schedules) { + this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd); + } + + return { dates: Array.from(unavailableDates).sort() }; + } + + /** + * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 + * 重叠判定:start1 <= end2 AND start2 <= end1 + */ + async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) { + const qb = this.repo + .createQueryBuilder('r') + .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') + .where('r.classroomId = :cid', { cid: classroomId }) + .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) + .andWhere('r.startDate <= :end', { end: endDate }) + .andWhere('r.endDate >= :start', { start: startDate }); + if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); + const rentals = await qb.getMany(); + + // 检测同一教室同一日期段是否存在内部排课 + const scheduleCandidates = await this.scheduleRepo + .createQueryBuilder('cs') + .where('cs.classroomId = :cid', { cid: classroomId }) + .andWhere('cs.status = :status', { status: 'active' }) + .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) + .andWhere('cs.startDate <= :end', { end: endDate }) + .andWhere('cs.endDate >= :start', { start: startDate }) + .getMany(); + const scheduleConflicts = scheduleCandidates.filter((schedule) => + this.hasScheduleOccurrence(schedule, startDate, endDate), + ); + + if (scheduleConflicts.length > 0) { + throw new ConflictException({ + message: '该教室在此时间段已有排课', + conflicts: scheduleConflicts.map((s) => ({ + id: s.id, + startDate: s.startDate, + endDate: s.endDate, + organizationName: `[内部排课] ${s.subject}`, + })), + }); + } + + return rentals; + } + + private hasScheduleOccurrence( + schedule: ClassSchedule, + startDate: string, + endDate: string, + ): boolean { + const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; + const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; + if (overlapStart > overlapEnd) return false; + + const startUtc = this.toUtcDate(overlapStart); + const endUtc = this.toUtcDate(overlapEnd); + const startWeekDay = startUtc.getUTCDay() || 7; + const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; + startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); + return startUtc <= endUtc; + } + + private toUtcDate(date: string): Date { + const [year, month, day] = date.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day)); + } + + private addDateRange(dates: Set, startDate: string, endDate: string) { + const current = this.toUtcDate(startDate); + const end = this.toUtcDate(endDate); + while (current <= end) { + dates.add(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + } + + private addScheduleOccurrences( + dates: Set, + schedule: ClassSchedule, + startDate: string, + endDate: string, + ) { + const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; + const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; + if (overlapStart > overlapEnd) return; + + const current = this.toUtcDate(overlapStart); + const end = this.toUtcDate(overlapEnd); + const startWeekDay = current.getUTCDay() || 7; + current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); + while (current <= end) { + dates.add(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 7); + } + } + + + async getSchedule(year: number, month: number) { + const lastDay = new Date(year, month, 0).getDate(); + const first = `${year}-${String(month).padStart(2, '0')}-01`; + const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const classrooms = await this.classroomRepo.find({ + where: { status: Not(ClassroomStatus.ARCHIVED) }, + order: { building: 'ASC', name: 'ASC' }, + }); + const rentals = await this.repo + .createQueryBuilder('r') + .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') + .leftJoinAndSelect('r.classroom', 'classroom') + .where('r.status IN (:...statuses)', { + statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], + }) + .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) + .getMany(); + + const organizationMap = new Map(); + const matrix: Record> = {}; + const summary: Record< + number, + { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number } + > = {}; + + for (const cls of classrooms) { + matrix[cls.id] = {}; + summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 }; + } + + for (const rental of rentals) { + const start = new Date(rental.startDate); + const end = new Date(rental.endDate); + const monthStart = new Date(first); + const monthEnd = new Date(last); + const effStart = start < monthStart ? monthStart : start; + const effEnd = end > monthEnd ? monthEnd : end; + if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { + organizationMap.set(rental.lesseeOrganization.id, { + id: rental.lesseeOrganization.id, + name: rental.lesseeOrganization.name, + color: + rental.lesseeOrganization.color || + COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], + }); + } + for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { + const day = d.getDate(); + if (!matrix[rental.classroomId]) continue; + matrix[rental.classroomId][day] = { + scheduleType: 'RENTAL', + rentalId: rental.id, + organizationId: rental.lesseeOrganizationId, + organizationName: rental.lesseeOrganization?.name || '未知', + color: + rental.lesseeOrganization?.color || + COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], + hasContract: !!rental.contractPath, + }; + } + } + + // ── Overlay internal class schedules ── + const schedules = await this.scheduleRepo + .createQueryBuilder('s') + .leftJoinAndSelect('s.class', 'class') + .leftJoinAndSelect('s.teacher', 'teacher') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) + .getMany(); + + for (const sched of schedules) { + if (!sched.classroomId) continue; + const schedStart = new Date( + Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), + ); + const schedEnd = new Date( + Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), + ); + for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { + const dow = d.getDay() === 0 ? 7 : d.getDay(); + if (dow !== sched.weekDay) continue; + const day = d.getDate(); + if (!matrix[sched.classroomId]) continue; + matrix[sched.classroomId][day] = { + scheduleType: 'INTERNAL', + scheduleId: sched.id, + className: (sched.class as { name?: string } | null)?.name || '', + subject: sched.subject, + teacherName: (sched.teacher as { name?: string } | null)?.name || '', + startTime: sched.startTime, + endTime: sched.endTime, + color: '#52c41a', + }; + } + } + // 统计 + for (const cls of classrooms) { + const rented = Object.keys(matrix[cls.id]).length; + summary[cls.id].rentedDays = rented; + summary[cls.id].idleDays = lastDay - rented; + summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; + } + + return { + year, + month, + days: lastDay, + classrooms: classrooms.map((c) => ({ + id: c.id, + name: c.name, + building: c.building, + floor: c.floor, + roomType: c.roomType, + capacity: c.capacity, + })), + organizations: Array.from(organizationMap.values()), + matrix, + summary, + }; + } + withEffectiveStatus(rental: ClassroomRental) { + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const effectiveStatus = + rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today + ? ClassroomRentalStatus.ENDED + : rental.status; + return Object.assign(rental, { effectiveStatus }); + } + + /** + * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') + */ + + async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { + const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; + const weekDay = this.dateToWeekDay(rental.startDate); + let schedule = await this.scheduleRepo.findOne({ + where: { rentalId: rental.id, scheduleType: 'RENTAL' }, + }); + const data = { + classroomId: rental.classroomId, + classId: null, + weekDay, + startTime: '00:00', + endTime: '23:59', + startDate: rental.startDate, + endDate: rental.endDate, + subject: `${name} 租赁`, + teacherId: null, + scheduleType: 'RENTAL', + rentalId: rental.id, + status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', + notes: rental.notes, + }; + if (schedule) { + await this.scheduleRepo.update(schedule.id, data); + } else { + schedule = this.scheduleRepo.create(data); + await this.scheduleRepo.save(schedule); + } + } + + + dateToWeekDay(date: string): number { + const d = new Date(date); + const day = d.getDay(); + return day === 0 ? 7 : day; + } + +} diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 1422ef9..92b9662 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -19,6 +19,7 @@ import { ClassroomsService } from './classrooms.service'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; @@ -104,18 +105,9 @@ export class ClassroomsController { @Post() @RequirePermission('classroom:create') async create(@Body() dto: CreateClassroomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '新增教室', - targetId: result.id, - targetType: 'classroom', - detail: dto.name, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, }); return result; } @@ -123,18 +115,9 @@ export class ClassroomsController { @Put(':id') @RequirePermission('classroom:edit') async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '编辑教室', - targetId: +id, - targetType: 'classroom', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), }); return result; } @@ -142,17 +125,19 @@ export class ClassroomsController { @Delete(':id') @RequirePermission('classroom:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '归档教室', - targetId: +id, - targetType: 'classroom', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('classroom:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复', }); return result; } @@ -160,17 +145,9 @@ export class ClassroomsController { @Put(':id/restore') @RequirePermission('classroom:edit') async restore(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '恢复教室', - targetId: +id, - targetType: 'classroom', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', }); return result; } @@ -181,7 +158,7 @@ export class ClassroomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/classrooms/classrooms.module.ts b/apps/server/src/classrooms/classrooms.module.ts index 888edc4..5ed6ead 100644 --- a/apps/server/src/classrooms/classrooms.module.ts +++ b/apps/server/src/classrooms/classrooms.module.ts @@ -3,12 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Classroom } from '../entities/classroom.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceDevice } from '../entities/attendance-device.entity'; import { ClassroomsService } from './classrooms.service'; import { ClassroomsController } from './classrooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule], + imports: [ + TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule, AttendanceDevice]), + OperationLogsModule, + ], controllers: [ClassroomsController], providers: [ClassroomsService], exports: [ClassroomsService], diff --git a/apps/server/src/classrooms/classrooms.purge.controller.spec.ts b/apps/server/src/classrooms/classrooms.purge.controller.spec.ts new file mode 100644 index 0000000..ea2facd --- /dev/null +++ b/apps/server/src/classrooms/classrooms.purge.controller.spec.ts @@ -0,0 +1,23 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ClassroomsController } from './classrooms.controller'; + +describe('ClassroomsController purge route', () => { + it('requires classroom:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomsController.prototype.purge)).toEqual([ + 'classroom:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除教室(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassroomsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '教室', action: '永久删除教室', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classrooms/classrooms.purge.spec.ts b/apps/server/src/classrooms/classrooms.purge.spec.ts new file mode 100644 index 0000000..1a537a0 --- /dev/null +++ b/apps/server/src/classrooms/classrooms.purge.spec.ts @@ -0,0 +1,59 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassroomsService } from './classrooms.service'; + +describe('ClassroomsService.purge', () => { + const createService = (overrides?: { + classroom?: Record; + scheduleCount?: number; + rentalCount?: number; + deviceCount?: number; + }) => { + const classroom = { id: 1, name: '101教室', status: 'archived', ...overrides?.classroom }; + const repo = { + findOne: jest.fn().mockResolvedValue(classroom), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const scheduleRepo = { count: jest.fn().mockResolvedValue(overrides?.scheduleCount ?? 0) }; + const rentalRepo = { count: jest.fn().mockResolvedValue(overrides?.rentalCount ?? 0) }; + const deviceRepo = { count: jest.fn().mockResolvedValue(overrides?.deviceCount ?? 0) }; + const service = new ClassroomsService( + repo as never, + rentalRepo as never, + scheduleRepo as never, + deviceRepo as never, + ); + return { service, repo, scheduleRepo, rentalRepo, deviceRepo }; + }; + + it('rejects classrooms that are not archived', async () => { + const { service, repo } = createService({ classroom: { status: 'available' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档教室可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects classrooms with schedules, rentals, or devices', async () => { + const withSchedule = createService({ scheduleCount: 1 }); + await expect(withSchedule.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室存在排课记录,无法永久删除'), + ); + + const withRental = createService({ rentalCount: 1 }); + await expect(withRental.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室存在租赁订单,无法永久删除'), + ); + + const withDevice = createService({ deviceCount: 1 }); + await expect(withDevice.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室绑定了考勤机,无法永久删除'), + ); + expect(withDevice.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived classroom with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除教室(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index e01ba17..92339bd 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -4,6 +4,7 @@ import { Repository, Not, MoreThanOrEqual, Like } from 'typeorm'; import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceDevice } from '../entities/attendance-device.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; @Injectable() @@ -12,6 +13,7 @@ export class ClassroomsService { @InjectRepository(Classroom) private repo: Repository, @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + @InjectRepository(AttendanceDevice) private deviceRepo: Repository, ) {} async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) { @@ -113,6 +115,24 @@ export class ClassroomsService { return this.repo.findOne({ where: { id } }); } + async purge(id: number) { + const classroom = await this.repo.findOne({ where: { id } }); + if (!classroom) throw new NotFoundException('教室不存在'); + if (classroom.status !== ClassroomStatus.ARCHIVED) { + throw new BadRequestException('仅已归档教室可以永久删除,请先归档'); + } + const [scheduleCount, rentalCount, deviceCount] = await Promise.all([ + this.scheduleRepo.count({ where: { classroomId: id } }), + this.rentalRepo.count({ where: { classroomId: id } }), + this.deviceRepo.count({ where: { classroomId: id } }), + ]); + if (scheduleCount > 0) throw new BadRequestException('该教室存在排课记录,无法永久删除'); + if (rentalCount > 0) throw new BadRequestException('该教室存在租赁订单,无法永久删除'); + if (deviceCount > 0) throw new BadRequestException('该教室绑定了考勤机,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除教室(不可恢复)' }; + } + private withEffectiveStatus( classroom: Classroom, usage?: { @@ -214,17 +234,23 @@ export class ClassroomsService { }; const weekDay = weekDayMap[shanghaiParts]; - const schedules = await this.scheduleRepo + const qb = this.scheduleRepo .createQueryBuilder('s') .leftJoin('Class', 'c', 'c.id = s.classId') - .select('s.classroomId', 'classroomId') - .addSelect('s.startTime', 'startTime') - .addSelect('s.endTime', 'endTime') - .addSelect('s.startDate', 'startDate') - .addSelect('s.endDate', 'endDate') - .addSelect('s.weekDay', 'weekDay') - .addSelect('s.subject', 'subject') - .addSelect('c.name', 'className') + .select('s.classroomId', 'classroomId'); + const scheduleSelects = [ + ['s.startTime', 'startTime'], + ['s.endTime', 'endTime'], + ['s.startDate', 'startDate'], + ['s.endDate', 'endDate'], + ['s.weekDay', 'weekDay'], + ['s.subject', 'subject'], + ['c.name', 'className'], + ] as const; + for (const [column, alias] of scheduleSelects) { + qb.addSelect(column, alias); + } + const schedules = await qb .where('s.classroomId IN (:...ids)', { ids: classroomIds }) .andWhere('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) diff --git a/apps/server/src/common/batch-restore.services.spec.ts b/apps/server/src/common/batch-restore.services.spec.ts index 21ff641..7e19adc 100644 --- a/apps/server/src/common/batch-restore.services.spec.ts +++ b/apps/server/src/common/batch-restore.services.spec.ts @@ -1,5 +1,13 @@ +function makeExpensesService( + a: never, b: never, c: never, d: never, e: never, f: never, +) { + const operations = new ExpenseOperationsService(a, b, c, d, e, f); + return new ExpensesService(a, b, c, d, e, f, operations); +} + import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ExpensesService } from '../expenses/expenses.service'; +import { ExpenseOperationsService } from '../expenses/expense-operations.service'; import { OccupanciesService } from '../occupancies/occupancies.service'; import { RoomsService } from '../rooms/rooms.service'; import { StudentsService } from '../students/students.service'; @@ -41,7 +49,7 @@ describe('batch restore service semantics', () => { const rooms = new RoomsService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); - const expenses = new ExpensesService( + const expenses = makeExpensesService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); const occupancies = new OccupanciesService( @@ -129,7 +137,7 @@ describe('batch restore service semantics', () => { createQueryBuilder: jest.fn(() => qb), }; const billItemsRepo = { count: jest.fn().mockResolvedValue(1) }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, @@ -150,7 +158,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, { getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never, ); @@ -168,7 +176,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, { getRepository: jest.fn(() => ({ count })) } as never, ); @@ -185,7 +193,7 @@ describe('batch restore service semantics', () => { find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]), createQueryBuilder: jest.fn(), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException); @@ -201,7 +209,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({ @@ -220,7 +228,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({ @@ -233,7 +241,7 @@ describe('batch restore service semantics', () => { it('uses archived status when querying expense archive views', async () => { const roomQb = listQb(); const personalRepo = { find: jest.fn().mockResolvedValue([]) }; - const service = new ExpensesService( + const service = makeExpensesService( { createQueryBuilder: jest.fn(() => roomQb) } as never, personalRepo as never, {} as never, {} as never, {} as never, {} as never, @@ -245,7 +253,7 @@ describe('batch restore service semantics', () => { }); it('rejects invalid expense query status values', async () => { - const service = new ExpensesService( + const service = makeExpensesService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException); diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts new file mode 100644 index 0000000..5ac20f6 --- /dev/null +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -0,0 +1,241 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Bill } from '../entities/bill.entity'; +import { RoomExpense } from '../entities/room-expense.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; + +export function nextMonth(ym: string): string { + const d = new Date(`${ym}-01`); + d.setMonth(d.getMonth() + 1); + return d.toISOString().slice(0, 7) + '-01'; +} + +export function applyClassScope( + qb: { andWhere: (condition: string, parameters?: Record) => unknown }, + alias: string, + accessibleClassIds?: number[], +) { + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) { + qb.andWhere('1 = 0'); + return; + } + qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds }); + } +} + +@Injectable() +export class DashboardQueriesService { + constructor( + @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, + @InjectRepository(Bill) private readonly billRepo: Repository, + @InjectRepository(Occupancy) private readonly occRepo: Repository, + @InjectRepository(RoomExpense) private readonly expRepo: Repository, + ) {} + +async getAttendanceTrend( + attendanceRepo: Repository, + todayStr: string, + accessibleClassIds?: number[], + ) { + const thirtyDaysAgo = new Date(todayStr); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); + const startStr = thirtyDaysAgo.toISOString().slice(0, 10); + + const trendQb = attendanceRepo + .createQueryBuilder('a') + .select('a.attendanceDate', 'date') + .addSelect('a.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('a.attendanceDate >= :start', { start: startStr }) + .andWhere('a.attendanceDate <= :today', { today: todayStr }); + applyClassScope(trendQb, 'a', accessibleClassIds); + + const rows = await trendQb + .groupBy('a.attendanceDate') + .addGroupBy('a.status') + .orderBy('a.attendanceDate', 'ASC') + .getRawMany(); + + const dayMap = new Map(); + for (const row of rows) { + const d = dayMap.get(row.date) || { total: 0, present: 0 }; + const cnt = parseInt(row.count, 10); + d.total += cnt; + if (row.status === 'present') d.present += cnt; + dayMap.set(row.date, d); + } + + return Array.from(dayMap.entries()).map(([date, d]) => ({ + date, + rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0, + })); +} + + +async getIncomeTrend( + billRepo: Repository, + currentMonth: string, + ) { + const results: { month: string; amount: number }[] = []; + + for (let i = 5; i >= 0; i--) { + const d = new Date(`${currentMonth}-01`); + d.setMonth(d.getMonth() - i); + const m = d.toISOString().slice(0, 7); + + const row = await billRepo + .createQueryBuilder('b') + .select('SUM(b.totalAmount)', 'total') + .where('b.status = :paid', { paid: 'paid' }) + .andWhere('b.periodStart >= :start', { start: `${m}-01` }) + .andWhere('b.periodStart < :end', { end: nextMonth(m) }) + .getRawOne(); + + results.push({ + month: m, + amount: parseFloat(row?.total || '0'), + }); + } + + return results; +} + + +// 甘特图数据:每个宿舍的入住时间线 + +async getGanttData( + occRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + query?: { periodStart?: string; periodEnd?: string; building?: string }, + ) { + assertPeriodRange(query?.periodStart, query?.periodEnd); + const qb = occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('room.status != :archived', { archived: 'archived' }) + .orderBy('room.roomNumber', 'ASC') + .addOrderBy('o.checkInDate', 'ASC'); + + if (query?.building) { + qb.andWhere('room.building = :building', { building: query.building }); + } + if (query?.periodStart) { + qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart }); + } + if (query?.periodEnd) { + qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd }); + } + + const records = await qb.getMany(); + + // 按宿舍分组 + const roomMap = new Map[]>(); + for (const r of records) { + const key = r.room?.roomNumber || String(r.roomId); + if (!roomMap.has(key)) roomMap.set(key, []); + roomMap.get(key)!.push({ + studentName: r.student?.name || '未知', + studentId: r.studentId, + checkInDate: r.checkInDate, + checkOutDate: r.checkOutDate, + billingStartDate: r.billingStartDate, + billingEndDate: r.billingEndDate, + }); + } + + return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({ + roomNumber, + occupancies, + })); +} +// 费用统计 + +async getExpenseStats( + expRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + periodStart?: string, + periodEnd?: string, + ) { + assertPeriodRange(periodStart, periodEnd); + const qb = expRepo + .createQueryBuilder('e') + .select('e.expenseType', 'type') + .addSelect('SUM(e.amount)', 'total') + .groupBy('e.expenseType'); + if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); + if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); + return qb.getRawMany(); +} + +// 各宿舍费用排行 + +async getRoomExpenseRanking( + expRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + periodStart?: string, + periodEnd?: string, + ) { + assertPeriodRange(periodStart, periodEnd); + const qb = expRepo + .createQueryBuilder('e') + .leftJoin('e.room', 'room') + .select('room.roomNumber', 'roomNumber') + .addSelect('SUM(e.amount)', 'total') + .where('room.status != :archived', { archived: 'archived' }) + .groupBy('e.roomId') + .orderBy('total', 'DESC') + .limit(20); + if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); + if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); + return qb.getRawMany(); +} + +// 班级考勤排行 + +async getClassAttendanceRanking( + attendanceRepo: Repository, + applyClassScope: ( + qb: { andWhere: (condition: string, parameters?: Record) => unknown }, + alias: string, + accessibleClassIds?: number[], + ) => void, + accessibleClassIds?: number[], + ) { + if (accessibleClassIds?.length === 0) return { top: [], bottom: [] }; + const qb = attendanceRepo + .createQueryBuilder('a') + .leftJoin('a.class', 'class') + .select('class.id', 'classId') + .addSelect('class.name', 'className') + .addSelect('a.status', 'status') + .addSelect('COUNT(*)', 'count'); + applyClassScope(qb, 'a', accessibleClassIds); + qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); + const raw = await qb.getRawMany(); + + const classMap = new Map(); + for (const r of raw) { + if (!r.classId) continue; + if (!classMap.has(Number(r.classId))) + classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); + const entry = classMap.get(Number(r.classId))!; + const n = parseInt(r.count, 10); + entry.total += n; + if (r.status === 'present') entry.present += n; + } + + const ranked = Array.from(classMap.values()) + .map((e) => ({ + ...e, + rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0, + })) + .sort((a, b) => b.rate - a.rate); + + return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() }; +} + +} diff --git a/apps/server/src/dashboard/dashboard.module.ts b/apps/server/src/dashboard/dashboard.module.ts index 1805842..6171b92 100644 --- a/apps/server/src/dashboard/dashboard.module.ts +++ b/apps/server/src/dashboard/dashboard.module.ts @@ -14,6 +14,7 @@ import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { DashboardService } from './dashboard.service'; +import { DashboardQueriesService } from './dashboard-queries.service'; import { DashboardController } from './dashboard.controller'; @Module({ @@ -35,7 +36,7 @@ import { DashboardController } from './dashboard.controller'; ]), ], controllers: [DashboardController], - providers: [DashboardService], + providers: [DashboardService, DashboardQueriesService], exports: [DashboardService], }) export class DashboardModule {} diff --git a/apps/server/src/dashboard/dashboard.scope.spec.ts b/apps/server/src/dashboard/dashboard.scope.spec.ts index a76bba1..302a528 100644 --- a/apps/server/src/dashboard/dashboard.scope.spec.ts +++ b/apps/server/src/dashboard/dashboard.scope.spec.ts @@ -1,4 +1,8 @@ import { DashboardService } from './dashboard.service'; +import { DashboardQueriesService } from './dashboard-queries.service'; + +const queriesService = (attendanceRepo?: unknown) => + new DashboardQueriesService(attendanceRepo as never, {} as never, {} as never, {} as never); const createQb = () => ({ leftJoin: jest.fn().mockReturnThis(), @@ -32,7 +36,7 @@ describe('DashboardService — teacher class scope', () => { {} as never, {} as never, {} as never, - {}, + queriesService(attendanceRepo), ); await service.getClassAttendanceRanking([8, 9]); @@ -51,6 +55,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, attendanceRepo as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(attendanceRepo), ); await (service as unknown as { @@ -69,6 +74,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(), ); await expect((service[method] as (...values: never[]) => Promise)(...(args as never[]))) .rejects.toThrow('结束日期不能早于开始日期'); @@ -79,6 +85,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(), ); expect((service as unknown as { getChinaDate: (date: Date) => string }) .getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14'); diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 04f4a38..dbaae9b 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -14,6 +14,7 @@ import { Deposit } from '../entities/deposit.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassStudent } from '../entities/class-student.entity'; +import { DashboardQueriesService } from './dashboard-queries.service'; interface AgentAttendanceStatusRow { status: string; @@ -36,6 +37,7 @@ export class DashboardService { @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, + private readonly queries: DashboardQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -50,24 +52,39 @@ export class DashboardService { const totalStudents = accessibleClassIds ? await this.countStudentsInClasses(accessibleClassIds) : await this.studentRepo.count({ where: { status: 'active' } }); - const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: { isArchived: false } }); + const classCount = accessibleClassIds + ? accessibleClassIds.length + : await this.classRepo.count({ where: { isArchived: false } }); const attendanceQb = this.attendanceRepo .createQueryBuilder('attendance') .select('attendance.status', 'status') .addSelect('COUNT(attendance.id)', 'count') .where('attendance.attendanceDate = :today', { today }); this.applyClassScope(attendanceQb, 'attendance', accessibleClassIds); - const rows = await attendanceQb.groupBy('attendance.status').getRawMany(); - const attendanceByStatus = rows.reduce((result, row) => { - result[String(row.status)] = Number(row.count || 0); - return result; - }, {} as Record); + const rows = await attendanceQb + .groupBy('attendance.status') + .getRawMany(); + const attendanceByStatus = rows.reduce( + (result, row) => { + result[String(row.status)] = Number(row.count || 0); + return result; + }, + {} as Record, + ); const attendanceTotal = Object.values(attendanceByStatus).reduce( (sum, count) => sum + Number(count), 0, ); const present = attendanceByStatus.present ?? 0; - return { date: today, totalStudents, classCount, attendanceTotal, present, attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, attendanceByStatus }; + return { + date: today, + totalStudents, + classCount, + attendanceTotal, + present, + attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, + attendanceByStatus, + }; } async getStats(accessibleClassIds?: number[]) { @@ -117,12 +134,9 @@ export class DashboardService { this.applyClassScope(attTodayQb, 'a', accessibleClassIds); attTodayQb.groupBy('a.status'); const attTodayStats = await attTodayQb.getRawMany(); - const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0); const todayPresent = attTodayStats .filter((r) => r.status === 'present') .reduce((sum, r) => sum + parseInt(r.count, 10), 0); - const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0; - const incomeQb = this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') @@ -135,7 +149,6 @@ export class DashboardService { const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds); const incomeTrend = await this.getIncomeTrend(currentMonth); - // --- New stats --- const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: {} }); @@ -226,64 +239,32 @@ export class DashboardService { return new Set(classStudents.map((item) => item.studentId)).size; } - private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) { - const thirtyDaysAgo = new Date(todayStr); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); - const startStr = thirtyDaysAgo.toISOString().slice(0, 10); - - const trendQb = this.attendanceRepo - .createQueryBuilder('a') - .select('a.attendanceDate', 'date') - .addSelect('a.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('a.attendanceDate >= :start', { start: startStr }) - .andWhere('a.attendanceDate <= :today', { today: todayStr }); - this.applyClassScope(trendQb, 'a', accessibleClassIds); - - const rows = await trendQb - .groupBy('a.attendanceDate') - .addGroupBy('a.status') - .orderBy('a.attendanceDate', 'ASC') - .getRawMany(); - - const dayMap = new Map(); - for (const row of rows) { - const d = dayMap.get(row.date) || { total: 0, present: 0 }; - const cnt = parseInt(row.count, 10); - d.total += cnt; - if (row.status === 'present') d.present += cnt; - dayMap.set(row.date, d); - } - - return Array.from(dayMap.entries()).map(([date, d]) => ({ - date, - rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0, - })); + async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) { + return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds); } - private async getIncomeTrend(currentMonth: string) { - const results: { month: string; amount: number }[] = []; + async getIncomeTrend(currentMonth: string) { + return this.queries.getIncomeTrend(this.billRepo, currentMonth); + } - for (let i = 5; i >= 0; i--) { - const d = new Date(`${currentMonth}-01`); - d.setMonth(d.getMonth() - i); - const m = d.toISOString().slice(0, 7); + async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query); + } - const row = await this.billRepo - .createQueryBuilder('b') - .select('SUM(b.totalAmount)', 'total') - .where('b.status = :paid', { paid: 'paid' }) - .andWhere('b.periodStart >= :start', { start: `${m}-01` }) - .andWhere('b.periodStart < :end', { end: this.nextMonth(m) }) - .getRawOne(); + async getExpenseStats(periodStart?: string, periodEnd?: string) { + return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); + } - results.push({ - month: m, - amount: parseFloat(row?.total || '0'), - }); - } + async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); + } - return results; + async getClassAttendanceRanking(accessibleClassIds?: number[]) { + return this.queries.getClassAttendanceRanking( + this.attendanceRepo, + (qb, alias, ids) => this.applyClassScope(qb, alias, ids), + accessibleClassIds, + ); } private nextMonth(ym: string): string { @@ -292,114 +273,6 @@ export class DashboardService { return d.toISOString().slice(0, 7) + '-01'; } - // 甘特图数据:每个宿舍的入住时间线 - async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { - this.assertPeriodRange(query?.periodStart, query?.periodEnd); - const qb = this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('room.status != :archived', { archived: 'archived' }) - .orderBy('room.roomNumber', 'ASC') - .addOrderBy('o.checkInDate', 'ASC'); - - if (query?.building) { - qb.andWhere('room.building = :building', { building: query.building }); - } - if (query?.periodStart) { - qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart }); - } - if (query?.periodEnd) { - qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd }); - } - - const records = await qb.getMany(); - - // 按宿舍分组 - const roomMap = new Map[]>(); - for (const r of records) { - const key = r.room?.roomNumber || String(r.roomId); - if (!roomMap.has(key)) roomMap.set(key, []); - roomMap.get(key)!.push({ - studentName: r.student?.name || '未知', - studentId: r.studentId, - checkInDate: r.checkInDate, - checkOutDate: r.checkOutDate, - billingStartDate: r.billingStartDate, - billingEndDate: r.billingEndDate, - }); - } - - return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({ - roomNumber, - occupancies, - })); - } - // 费用统计 - async getExpenseStats(periodStart?: string, periodEnd?: string) { - this.assertPeriodRange(periodStart, periodEnd); - const qb = this.expRepo - .createQueryBuilder('e') - .select('e.expenseType', 'type') - .addSelect('SUM(e.amount)', 'total') - .groupBy('e.expenseType'); - if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); - if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); - return qb.getRawMany(); - } - - // 各宿舍费用排行 - async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { - this.assertPeriodRange(periodStart, periodEnd); - const qb = this.expRepo - .createQueryBuilder('e') - .leftJoin('e.room', 'room') - .select('room.roomNumber', 'roomNumber') - .addSelect('SUM(e.amount)', 'total') - .where('room.status != :archived', { archived: 'archived' }) - .groupBy('e.roomId') - .orderBy('total', 'DESC') - .limit(20); - if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); - if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); - return qb.getRawMany(); - } - - // 班级考勤排行 - async getClassAttendanceRanking(accessibleClassIds?: number[]) { - if (accessibleClassIds?.length === 0) return { top: [], bottom: [] }; - const qb = this.attendanceRepo - .createQueryBuilder('a') - .leftJoin('a.class', 'class') - .select('class.id', 'classId') - .addSelect('class.name', 'className') - .addSelect('a.status', 'status') - .addSelect('COUNT(*)', 'count'); - this.applyClassScope(qb, 'a', accessibleClassIds); - qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); - const raw = await qb.getRawMany(); - - const classMap = new Map(); - for (const r of raw) { - if (!r.classId) continue; - if (!classMap.has(Number(r.classId))) - classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); - const entry = classMap.get(Number(r.classId))!; - const n = parseInt(r.count, 10); - entry.total += n; - if (r.status === 'present') entry.present += n; - } - - const ranked = Array.from(classMap.values()) - .map((e) => ({ - ...e, - rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0, - })) - .sort((a, b) => b.rate - a.rate); - - return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() }; - } - async getClassroomOccupancy() { const classrooms = await this.classroomRepo.find({ where: { status: 'available' as const }, diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 71fa817..060300e 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -25,7 +25,7 @@ import { } from './dto/deposit.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @UseGuards(JwtAuthGuard) @@ -78,31 +78,11 @@ export class DepositsController { @Post() @RequirePermission('deposit:create') async create(@Body() dto: CreateDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '收取押金', - targetId: result.id, - targetType: 'deposit', - detail: `学生${dto.studentId} ¥${dto.amount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`, }); - // Send deposit_due notification - try { - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: 'deposit_due', - title: '押金待缴', - content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`, - }); - } - } catch (_) { /* don't block response */ } + await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`); return result; } @@ -110,17 +90,9 @@ export class DepositsController { @Post('batch') @RequirePermission('deposit:create') async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCreate(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '批量收取押金', - targetType: 'deposit', - detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, }); return result; } @@ -132,18 +104,9 @@ export class DepositsController { @Body() body: CreateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addInstallment(id, body.amount, body.dueDate); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '新增分期', - targetId: result.id, - targetType: 'deposit-installment', - detail: `押金${id} 新增分期 ¥${result.amount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`, }); return result; } @@ -155,18 +118,9 @@ export class DepositsController { @Body() body: UpdateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateInstallment(installmentId, body); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '更新分期', - targetId: installmentId, - targetType: 'deposit-installment', - detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, }); return result; } @@ -177,18 +131,9 @@ export class DepositsController { @Param('installmentId', ParseIntPipe) installmentId: number, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deleteInstallment(installmentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '归档分期', - targetId: installmentId, - targetType: 'deposit-installment', - detail: `归档分期${installmentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`, }); return result; } @@ -196,48 +141,46 @@ export class DepositsController { @Put(':id/refund') @RequirePermission('deposit:refund') async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.refund(id, dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '退还押金', - targetId: id, - targetType: 'deposit', - detail: `退还全部可用押金 ¥${result.refundAmount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`, }); - // Send deposit_refunded notification - try { - const student = await this.studentRepo.findOne({ where: { id: result.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: 'deposit_refunded', - title: '押金已退还', - content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`, - }); - } - } catch (_) { /* don't block response */ } + await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`); return result; } + private async notifyDeposit( + studentId: number, + type: 'deposit_due' | 'deposit_refunded', + title: string, + content: string, + ): Promise { + try { + const student = await this.studentRepo.findOne({ where: { id: studentId } }); + if (student?.userId) { + void this.notificationsService.create({ recipientIds: [student.userId], type, title, content }); + } + } catch { + // 通知失败不影响主流程 + } + } + @Delete(':id') @RequirePermission('deposit:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '归档押金记录', - targetId: id, - targetType: 'deposit', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('deposit:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复', }); return result; } diff --git a/apps/server/src/deposits/deposits.purge.controller.spec.ts b/apps/server/src/deposits/deposits.purge.controller.spec.ts new file mode 100644 index 0000000..ee54720 --- /dev/null +++ b/apps/server/src/deposits/deposits.purge.controller.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { DepositsController } from './deposits.controller'; + +describe('DepositsController purge route', () => { + it('requires deposit:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([ + 'deposit:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new DepositsController( + service as never, + { log } as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/deposits/deposits.purge.spec.ts b/apps/server/src/deposits/deposits.purge.spec.ts new file mode 100644 index 0000000..e8ab93f --- /dev/null +++ b/apps/server/src/deposits/deposits.purge.spec.ts @@ -0,0 +1,65 @@ +import { BadRequestException } from '@nestjs/common'; +import { DepositsService } from './deposits.service'; + +describe('DepositsService.purge', () => { + const createService = (overrides?: { deposit?: Record }) => { + const deposit = { + id: 1, + studentId: 2, + amount: 500, + status: 'archived', + refundAmount: null, + deductionAmount: 0, + ...overrides?.deposit, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(deposit), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const installmentRepo = { count: jest.fn().mockResolvedValue(0) }; + const service = new DepositsService( + repo as never, + installmentRepo as never, + {} as never, + ); + return { service, repo, installmentRepo }; + }; + + it('rejects deposits that are not archived', async () => { + const { service, repo } = createService({ deposit: { status: 'paid' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档押金可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects deposits with refund or deduction amounts', async () => { + const withRefund = createService({ deposit: { refundAmount: 100 } }); + await expect(withRefund.service.purge(1)).rejects.toThrow( + new BadRequestException('该押金已有退款金额,无法永久删除'), + ); + + const withDeduction = createService({ deposit: { deductionAmount: 50 } }); + await expect(withDeduction.service.purge(1)).rejects.toThrow( + new BadRequestException('该押金已有抵扣金额,无法永久删除'), + ); + expect(withDeduction.repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects deposits with paid installments', async () => { + const { service, installmentRepo, repo } = createService(); + installmentRepo.count.mockResolvedValue(1); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该押金存在已支付分期,无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived deposit with no paid history', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除押金(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index daeef11..8dc741a 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -67,16 +67,21 @@ export class DepositsService { .leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', { archived: 'archived', }) - .select('student.id', 'studentId') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .addSelect('room.id', 'roomId') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.roomType', 'roomType') - .addSelect('room.capacity', 'capacity') - .addSelect('deposit.amount', 'depositAmount') - .where('o.status = :activeStatus', { activeStatus: 'active' }) + .select('student.id', 'studentId'); + const eligibleSelects = [ + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ['room.id', 'roomId'], + ['room.roomNumber', 'roomNumber'], + ['room.building', 'building'], + ['room.roomType', 'roomType'], + ['room.capacity', 'capacity'], + ['deposit.amount', 'depositAmount'], + ] as const; + for (const [column, alias] of eligibleSelects) { + qb.addSelect(column, alias); + } + qb.where('o.status = :activeStatus', { activeStatus: 'active' }) .andWhere('o.checkOutDate IS NULL') .andWhere('student.status = :studentStatus', { studentStatus: 'active' }) .orderBy('room.building', 'ASC') @@ -167,15 +172,20 @@ export class DepositsService { const qb = this.repo .createQueryBuilder('d') .leftJoin('d.student', 'student') - .select('d.id', 'id') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .addSelect('d.amount', 'amount') - .addSelect('d.status', 'status') - .addSelect('d.paidDate', 'paidDate') - .addSelect('d.refundAmount', 'refundAmount') - .addSelect('d.refundDate', 'refundDate') - .where('d.status != :archived', { archived: 'archived' }); + .select('d.id', 'id'); + const depositSelects = [ + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ['d.amount', 'amount'], + ['d.status', 'status'], + ['d.paidDate', 'paidDate'], + ['d.refundAmount', 'refundAmount'], + ['d.refundDate', 'refundDate'], + ] as const; + for (const [column, alias] of depositSelects) { + qb.addSelect(column, alias); + } + qb.where('d.status != :archived', { archived: 'archived' }); if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', @@ -226,8 +236,8 @@ export class DepositsService { existing.paidDate = dto.paidDate; existing.status = 'paid'; existing.recordedBy = userId ?? null; - existing.refundDate = null as unknown as string; - existing.refundAmount = null as unknown as number; + existing.refundDate = null; + existing.refundAmount = null; existing.refundedBy = null; existing.refundedAt = null; if (dto.notes) existing.notes = dto.notes; @@ -309,6 +319,28 @@ export class DepositsService { return { message: '已归档' }; } + async purge(id: number) { + const deposit = await this.repo.findOne({ where: { id } }); + if (!deposit) throw new NotFoundException('押金记录不存在'); + if (deposit.status !== 'archived') { + throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + } + if (Number(deposit.refundAmount || 0) > 0) { + throw new BadRequestException('该押金已有退款金额,无法永久删除'); + } + if (Number(deposit.deductionAmount || 0) > 0) { + throw new BadRequestException('该押金已有抵扣金额,无法永久删除'); + } + const paidInstallments = await this.installmentRepo.count({ + where: { depositId: id, status: 'paid' }, + }); + if (paidInstallments > 0) { + throw new BadRequestException('该押金存在已支付分期,无法永久删除'); + } + await this.repo.delete(id); + return { message: '已永久删除押金(不可恢复)' }; + } + async getStats() { const qb = this.repo .createQueryBuilder('d') diff --git a/apps/server/src/entities/class-schedule.entity.ts b/apps/server/src/entities/class-schedule.entity.ts index 0e7ca4d..abbd542 100644 --- a/apps/server/src/entities/class-schedule.entity.ts +++ b/apps/server/src/entities/class-schedule.entity.ts @@ -8,6 +8,8 @@ import { JoinColumn, Check, } from 'typeorm'; +import type { Class } from './class.entity'; +import type { User } from './user.entity'; export enum ScheduleType { INTERNAL = 'INTERNAL', @@ -26,7 +28,7 @@ export class ClassSchedule { // Forward reference — Class entity @ManyToOne('Class', { nullable: true }) @JoinColumn({ name: 'class_id' }) - class: unknown; + class: Class | null; @Column({ name: 'classroom_id', type: 'integer' }) classroomId: number; @@ -64,7 +66,7 @@ export class ClassSchedule { // Forward reference — User entity @ManyToOne('User', { nullable: true }) @JoinColumn({ name: 'teacher_id' }) - teacher: unknown; + teacher: User | null; @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' }) scheduleType: string; diff --git a/apps/server/src/entities/classroom-rental.entity.ts b/apps/server/src/entities/classroom-rental.entity.ts index fd86c8d..2b2edec 100644 --- a/apps/server/src/entities/classroom-rental.entity.ts +++ b/apps/server/src/entities/classroom-rental.entity.ts @@ -51,11 +51,11 @@ export class ClassroomRental { endDate: string; // 合同 PDF 相对路径(相对 UPLOAD_DIR),仅存文件名 - @Column({ name: 'contract_path', length: 255, nullable: true }) - contractPath: string; + @Column({ name: 'contract_path', type: 'varchar', length: 255, nullable: true }) + contractPath: string | null; - @Column({ name: 'contract_original_name', length: 255, nullable: true }) - contractOriginalName: string; + @Column({ name: 'contract_original_name', type: 'varchar', length: 255, nullable: true }) + contractOriginalName: string | null; @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true }) dailyRate: number; diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts index 4e3d203..ec533d7 100644 --- a/apps/server/src/entities/deposit.entity.ts +++ b/apps/server/src/entities/deposit.entity.ts @@ -29,10 +29,10 @@ export class Deposit { paidDate: string; @Column({ name: 'refund_date', type: 'date', nullable: true }) - refundDate: string; + refundDate: string | null; @Column({ name: 'refund_amount', type: 'decimal', precision: 10, scale: 2, nullable: true }) - refundAmount: number; + refundAmount: number | null; @Column({ name: 'deduction_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) deductionAmount: number; diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 140e028..90f991b 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -53,3 +53,6 @@ export { AiForm, AiReview, } from '../ai-chat/entities'; +export { ImportRun } from '../imports/entities/import-run.entity'; +export { ImportStep } from '../imports/entities/import-step.entity'; +export { ImportRow } from '../imports/entities/import-row.entity'; diff --git a/apps/server/src/entities/room.entity.ts b/apps/server/src/entities/room.entity.ts index c5290b2..343b7a8 100644 --- a/apps/server/src/entities/room.entity.ts +++ b/apps/server/src/entities/room.entity.ts @@ -1,12 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - OneToMany, - ManyToOne, - JoinColumn, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm'; import { Occupancy } from './occupancy.entity'; import { RoomExpense } from './room-expense.entity'; diff --git a/apps/server/src/exams/exams.controller.spec.ts b/apps/server/src/exams/exams.controller.spec.ts index e2cb19c..724195a 100644 --- a/apps/server/src/exams/exams.controller.spec.ts +++ b/apps/server/src/exams/exams.controller.spec.ts @@ -60,4 +60,24 @@ describe('ExamsController batch archive and restore', () => { ['批量恢复考试', 'IDs: 3,4'], ]); }); + + it('requires exam:purge and writes permanent delete logs', async () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.purge)).toEqual([ + 'exam:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.batchPurge), + ).toEqual(['exam:purge']); + + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除考试(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ExamsController(service as never, { log } as never); + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1, 7, true); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '考试管理', action: '永久删除考试', targetId: 1 }), + ); + }); }); diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts index 610a882..cdcf084 100644 --- a/apps/server/src/exams/exams.controller.ts +++ b/apps/server/src/exams/exams.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseIntPipe, @@ -14,7 +15,7 @@ import { } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { BatchIdsDto } from '../common/batch-ids.dto'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import type { AuthenticatedUser } from '../authorization'; @@ -55,15 +56,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '批量归档考试', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '批量归档考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -76,15 +70,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '批量恢复考试', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '批量恢复考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -99,17 +86,8 @@ export class ExamsController { @RequirePermission('exam:view') async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '创建考试', - targetId: result.id, - targetType: 'exam', - detail: `${dto.examName} - ${dto.subject}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '创建考试', targetId: result.id, targetType: 'exam', detail: `${dto.examName} - ${dto.subject}`, }); return result; } @@ -121,16 +99,8 @@ export class ExamsController { @Request() req: AuthenticatedRequest, ) { const result = await this.service.archive(id, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '归档考试', - targetId: id, - targetType: 'exam', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '归档考试', targetId: id, targetType: 'exam', }); return result; } @@ -142,16 +112,35 @@ export class ExamsController { @Request() req: AuthenticatedRequest, ) { const result = await this.service.restore(id, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '恢复考试', - targetId: id, - targetType: 'exam', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '恢复考试', targetId: id, targetType: 'exam', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('exam:purge') + async purge( + @Param('id', ParseIntPipe) id: number, + @Request() req: AuthenticatedRequest, + ) { + const result = await this.service.purge(id, req.user.id, this.canManageAll(req)); + await logAudit(this.logService, req, { + module: '考试管理', action: '永久删除考试', targetId: id, targetType: 'exam', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('exam:purge') + async batchPurge(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.batchPurge( + dto.ids, + req.user.id, + this.canManageAll(req), + ); + await logAudit(this.logService, req, { + module: '考试管理', action: '批量永久删除考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -171,17 +160,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', - targetId: scoreId, - targetType: 'exam_score', - detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, }); return result; } diff --git a/apps/server/src/exams/exams.purge.spec.ts b/apps/server/src/exams/exams.purge.spec.ts new file mode 100644 index 0000000..54af2f2 --- /dev/null +++ b/apps/server/src/exams/exams.purge.spec.ts @@ -0,0 +1,56 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExamsService } from './exams.service'; + +describe('ExamsService.purge', () => { + const createService = (overrides?: { exam?: Record }) => { + const exam = { id: 1, examName: '月考', classId: 2, status: 'archived', ...overrides?.exam }; + const examRepo = { + findOne: jest.fn().mockResolvedValue(exam), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([exam]), + }; + const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) }; + const service = new ExamsService( + examRepo as never, + {} as never, + {} as never, + {} as never, + classTeacherRepo as never, + {} as never, + ); + return { service, examRepo, classTeacherRepo }; + }; + + it('rejects exams that are not archived', async () => { + const { service, examRepo } = createService({ exam: { status: 'active' } }); + await expect(service.purge(1, 7, true)).rejects.toThrow( + new BadRequestException('仅已归档考试可以永久删除,请先归档'), + ); + expect(examRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived exam and its scores', async () => { + const { service, examRepo } = createService(); + await expect(service.purge(1, 7, true)).resolves.toEqual({ + message: '已永久删除考试(不可恢复)', + }); + expect(examRepo.delete).toHaveBeenCalledWith(1); + }); + + it('checks class access before purge', async () => { + const { service, classTeacherRepo } = createService(); + classTeacherRepo.findOne.mockResolvedValue(null); + await expect(service.purge(1, 7, false)).rejects.toThrow('只能访问自己被分配的班级'); + }); + + it('batch purge returns deleted and skipped', async () => { + const { service, examRepo } = createService(); + examRepo.find = jest.fn().mockResolvedValue([ + { id: 1, examName: '月考', classId: 2, status: 'archived' }, + { id: 2, examName: '期中', classId: 2, status: 'active' }, + ]); + const result = await service.batchPurge([1, 2], 7, true); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(examRepo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts index ff3a9ee..8f543ed 100644 --- a/apps/server/src/exams/exams.service.ts +++ b/apps/server/src/exams/exams.service.ts @@ -194,6 +194,34 @@ export class ExamsService { return { success: true }; } + async purge(id: number, userId: number, canManageAll: boolean) { + const exam = await this.examRepo.findOne({ where: { id } }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + if (exam.status !== 'archived') throw new BadRequestException('仅已归档考试可以永久删除,请先归档'); + await this.examRepo.delete(id); + return { message: '已永久删除考试(不可恢复)' }; + } + + async batchPurge(ids: number[], userId: number, canManageAll: boolean) { + const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除'); + const deleted: number[] = []; + const skipped: string[] = []; + for (const exam of exams) { + if (exam.status !== 'archived') { + skipped.push(`${exam.examName}(未归档)`); + continue; + } + await this.examRepo.delete(exam.id); + deleted.push(exam.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 场考试(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchArchive(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '归档'); const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id); @@ -220,7 +248,7 @@ export class ExamsService { ids: number[], userId: number, canManageAll: boolean, - action: '归档' | '恢复', + action: '归档' | '恢复' | '永久删除', ) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`); diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts new file mode 100644 index 0000000..2f6e2ad --- /dev/null +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -0,0 +1,438 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; +import { RoomExpense, PersonalExpense, Room, Student } from '../entities'; +import { BillsService } from '../bills/bills.service'; +import { RoomsService } from '../rooms/rooms.service'; +import type { CreatePersonalExpenseDto } from './dto/expense.dto'; + +@Injectable() +export class ExpenseOperationsService { + constructor( + @InjectRepository(RoomExpense) private roomExpRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpRepo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + private billsService: BillsService, + private dataSource: DataSource, + ) {} + + private assertPositiveAmount(amount: number) { + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException('费用金额最多保留两位小数'); + } + if (amount <= 0) throw new BadRequestException('费用金额必须大于0'); + } + + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + + async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { + this.assertPositiveAmount(dto.amount); + const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); + return this.personalExpRepo.save(entity); + } + + async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) { + const status = query?.status ?? 'active'; + if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); + const where: Record = { status }; + if (query?.studentId) where.studentId = query.studentId; + return this.personalExpRepo.find({ + where, + relations: ['student'], + order: { createdAt: 'DESC' }, + }); + } + + async deletePersonalExpense(id: number) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单'); + if (e.status === 'archived') throw new BadRequestException('费用记录已归档'); + await this.personalExpRepo.update(id, { status: 'archived' }); + return { message: '已归档' }; + } + + async batchDeletePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录'); + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + if (existing.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + const result = await this.personalExpRepo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: uniqueIds }) + .execute(); + return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; + } + + async batchRestorePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const targets = existing.filter((expense) => expense.status === 'archived'); + if (targets.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + + const targetIds = targets.map((expense) => expense.id); + const skipped = existing.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.personalExpRepo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped }; + } + + async purgePersonalExpense(id: number) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { personalExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单明细的个人费用不能永久删除,请先取消账单'); + await this.personalExpRepo.delete(id); + return { message: '已永久删除个人费用(不可恢复)' }; + } + + async batchPurgePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的个人费用'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { personalExpenseId: In(uniqueIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单明细的个人费用'); + if (existing.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + + const deleted: number[] = []; + const skipped: string[] = []; + for (const e of existing) { + if (e.status !== 'archived') { + skipped.push(`记录${e.id}(未归档)`); + continue; + } + await this.personalExpRepo.delete(e.id); + deleted.push(e.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条个人费用(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async updatePersonalExpense(id: number, dto: Partial) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单'); + if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); + if (dto.studentId !== undefined && dto.studentId !== e.studentId) { + const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + } + Object.assign(e, dto); + return this.personalExpRepo.save(e); + } + + /** + * 水电费Excel批量导入 + * Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额 + * 时间格式: "2026-01-21 - 2026-02-08" + */ + async batchImportUtilityExpenses( + rows: { + periodStr: string; + roomNumber: string; + electricityAmount: number; + electricityFee: number; + waterAmount: number; + waterFee: number; + totalFee: number; + }[], + userId?: number, + ) { + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; + + if (!row.roomNumber?.trim()) { + skipped++; + continue; + } + + try { + // 查找或创建宿舍 + let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (!room) { + const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); + room = await this.roomRepo.save( + this.roomRepo.create({ + roomNumber: row.roomNumber.trim(), + building: parsed.building || undefined, + floor: parsed.floor || undefined, + capacity: parsed.capacity || 4, + roomType: parsed.roomType || undefined, + }), + ); + } + + // 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08" + let periodStart = ''; + let periodEnd = ''; + if (row.periodStr) { + // 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符) + let parts = row.periodStr.split(/\s+[-~~]\s+/); + if (parts.length < 2) { + // 回退:尝试用正则提取 YYYY-MM-DD 格式的日期 + const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g); + if (dateMatches && dateMatches.length >= 2) { + parts = [dateMatches[0], dateMatches[1]]; + } + } + if (parts.length >= 2) { + periodStart = this.normalizeDate(parts[0].trim()); + periodEnd = this.normalizeDate(parts[1].trim()); + } + } + if (!periodStart || !periodEnd) { + errors.push(`第${rowNum}行: 时间格式无法解析 "${row.periodStr}"`); + skipped++; + continue; + } + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`); + skipped++; + continue; + } + + // 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失, + // 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。 + if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) { + errors.push( + `第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`, + ); + skipped++; + continue; + } + + const existing = await this.roomExpRepo.find({ + where: [ + { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` }, + { importKey: `${room.id}:${periodStart}:${periodEnd}:water` }, + ], + }); + const byType = new Map(existing.map((expense) => [expense.expenseType, expense])); + + let savedAny = false; + if (row.electricityFee > 0) { + await this.importUtilityExpense( + room.id, + 'electricity', + periodStart, + periodEnd, + row.electricityFee, + `电量${row.electricityAmount}kWh`, + byType, + userId!, + ); + savedAny = true; + } + + if (row.waterFee > 0) { + await this.importUtilityExpense( + room.id, + 'water', + periodStart, + periodEnd, + row.waterFee, + `用水${row.waterAmount}吨`, + byType, + userId!, + ); + savedAny = true; + } + + if (savedAny) imported++; + else { + skipped++; + errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); + } + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); + skipped++; + } + } + + return { + message: + imported > 0 + ? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped} 条` : ''}` + : `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`, + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }; + } + + private async importUtilityExpense( + roomId: number, + expenseType: 'electricity' | 'water', + periodStart: string, + periodEnd: string, + amount: number, + description: string, + byType: Map, + recordedBy: number, + ): Promise { + const expense = byType.get(expenseType) || this.roomExpRepo.create({ + roomId, + expenseType, + periodStart, + periodEnd, + importKey: `${roomId}:${periodStart}:${periodEnd}:${expenseType}`, + }); + if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { + throw new BadRequestException(`该周期${expenseType === 'electricity' ? '电费' : '水费'}已计入账单,不能覆盖`); + } + expense.amount = amount; + expense.description = description; + expense.recordedBy = recordedBy; + await this.roomExpRepo.save(expense); + } + + /** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */ + private normalizeDate(s: string): string { + if (!s) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + const m = s.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/); + if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`; + return s; + } + + /** + * 个人附加费Excel批量导入 + * Excel格式: 学生姓名|费用类型|金额|费用日期|说明 + */ + async batchImportPersonalExpenses( + rows: { + studentName: string; + expenseType: string; + amount: number; + expenseDate: string; + description?: string; + }[], + userId?: number, + ) { + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; + + if (!row.studentName?.trim()) { + skipped++; + continue; + } + + try { + // 查找学生 + const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } }); + if (!student) { + errors.push(`第${rowNum}行: 学生"${row.studentName}"未找到`); + skipped++; + continue; + } + + // 解析费用类型 + const expenseType = row.expenseType?.trim() || ''; + if (!expenseType) { + errors.push(`第${rowNum}行: 费用类型不能为空`); + skipped++; + continue; + } + + // 解析日期 + let expenseDate = row.expenseDate?.trim() || ''; + if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { + // 尝试从各种格式解析 + const dateMatch = expenseDate.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/); + if (dateMatch) { + expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`; + } else { + errors.push(`第${rowNum}行: 日期格式"${row.expenseDate}"无效,需要YYYY-MM-DD`); + skipped++; + continue; + } + } + + // 校验金额 + try { + this.assertPositiveAmount(row.amount); + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); + skipped++; + continue; + } + + await this.personalExpRepo.save( + this.personalExpRepo.create({ + studentId: student.id, + expenseType, + amount: row.amount, + expenseDate, + description: row.description || undefined, + recordedBy: userId, + }), + ); + + imported++; + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); + skipped++; + } + } + + return { + message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped} 条`, + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }; + } +} diff --git a/apps/server/src/expenses/expenses.boundaries.spec.ts b/apps/server/src/expenses/expenses.boundaries.spec.ts index 1b54e03..dc0bfdc 100644 --- a/apps/server/src/expenses/expenses.boundaries.spec.ts +++ b/apps/server/src/expenses/expenses.boundaries.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; import { PersonalExpense } from '../entities/personal-expense.entity'; const qb = (affected = 1) => ({ @@ -35,7 +36,25 @@ function createService(options?: { }; const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) }; return { - service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any), + service: (() => { + const operations = new ExpenseOperationsService( + roomExpRepo as any, + personalExpRepo as any, + roomRepo as any, + studentRepo as any, + {} as any, + undefined as any, + ); + return new ExpensesService( + roomExpRepo as any, + personalExpRepo as any, + roomRepo as any, + studentRepo as any, + {} as any, + undefined as any, + operations, + ); + })(), roomExpRepo, personalExpRepo, roomRepo, diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 9a2f7e4..d9dcd6b 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -31,7 +31,7 @@ import { } from './dto/expense.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; @@ -91,17 +91,8 @@ export class ExpensesController { @RequirePermission('expense:create') async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) { const result = await this.service.createStudentUtilityBill(dto, req.user?.id); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入学生水电费并出账', - targetId: result.bill.id, - targetType: 'bill', - detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, }); return result; } @@ -109,18 +100,9 @@ export class ExpensesController { @Post('room') @RequirePermission('expense:create') async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.createRoomExpense(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入费用', - targetId: result.id, - targetType: 'room_expense', - detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -128,16 +110,9 @@ export class ExpensesController { @Post('room/batch') @RequirePermission('expense:create') async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量录入费用', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto), }); return result; } @@ -151,17 +126,9 @@ export class ExpensesController { @Delete('room/:id') @RequirePermission('expense:delete') async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deleteRoomExpense(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '归档费用', - targetId: id, - targetType: 'room_expense', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense', }); return result; } @@ -169,16 +136,29 @@ export class ExpensesController { @Post('room/batch-delete') @RequirePermission('expense:delete') async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchDeleteRoomExpenses(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量归档宿舍费用', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete('room/:id/permanent') + @RequirePermission('expense:purge') + async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purgeRoomExpense(id); + await logAudit(this.logService, req, { + module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('room/batch-permanent-delete') + @RequirePermission('expense:purge') + async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurgeRoomExpenses(body.ids || []); + await logAudit(this.logService, req, { + module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -187,16 +167,9 @@ export class ExpensesController { @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestoreRoomExpenses(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量恢复宿舍费用', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -208,18 +181,9 @@ export class ExpensesController { @Body() dto: UpdateRoomExpenseDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateRoomExpense(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '编辑费用', - targetId: id, - targetType: 'room_expense', - detail: `¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '编辑费用', targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -227,16 +191,9 @@ export class ExpensesController { @Post('personal') @RequirePermission('expense:create') async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.createPersonalExpense(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入费用', - detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -250,16 +207,9 @@ export class ExpensesController { @Delete('personal/:id') @RequirePermission('expense:delete') async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deletePersonalExpense(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '归档费用', - targetId: id, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '归档费用', targetId: id, }); return result; } @@ -267,16 +217,29 @@ export class ExpensesController { @Post('personal/batch-delete') @RequirePermission('expense:delete') async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchDeletePersonalExpenses(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量归档个人费用', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete('personal/:id/permanent') + @RequirePermission('expense:purge') + async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purgePersonalExpense(id); + await logAudit(this.logService, req, { + module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('personal/batch-permanent-delete') + @RequirePermission('expense:purge') + async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurgePersonalExpenses(body.ids || []); + await logAudit(this.logService, req, { + module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -285,16 +248,9 @@ export class ExpensesController { @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestorePersonalExpenses(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量恢复个人费用', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -306,17 +262,9 @@ export class ExpensesController { @Body() dto: UpdatePersonalExpenseDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updatePersonalExpense(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '编辑费用', - targetId: id, - detail: `¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '编辑费用', targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -361,9 +309,8 @@ export class ExpensesController { @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { @@ -381,14 +328,8 @@ export class ExpensesController { }); }); const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '导入水电费', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '导入水电费', detail: result.message, }); return result; } @@ -433,9 +374,8 @@ export class ExpensesController { @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { @@ -451,14 +391,8 @@ export class ExpensesController { }); }); const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '导入个人附加费', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '导入个人附加费', detail: result.message, }); return result; } diff --git a/apps/server/src/expenses/expenses.module.ts b/apps/server/src/expenses/expenses.module.ts index 475f48c..51ddb6b 100644 --- a/apps/server/src/expenses/expenses.module.ts +++ b/apps/server/src/expenses/expenses.module.ts @@ -5,6 +5,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; import { ExpensesController } from './expenses.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { BillsModule } from '../bills/bills.module'; @@ -16,7 +17,7 @@ import { BillsModule } from '../bills/bills.module'; BillsModule, ], controllers: [ExpensesController], - providers: [ExpensesService], + providers: [ExpensesService, ExpenseOperationsService], exports: [ExpensesService], }) export class ExpensesModule {} diff --git a/apps/server/src/expenses/expenses.purge.controller.spec.ts b/apps/server/src/expenses/expenses.purge.controller.spec.ts new file mode 100644 index 0000000..a7d497c --- /dev/null +++ b/apps/server/src/expenses/expenses.purge.controller.spec.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ExpensesController } from './expenses.controller'; + +describe('ExpensesController purge routes', () => { + it('requires expense:purge on permanent delete routes', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgeRoomExpense), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgeRoomExpenses), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgePersonalExpense), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgePersonalExpenses), + ).toEqual(['expense:purge']); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purgeRoomExpense: jest.fn().mockResolvedValue({ message: '已永久删除宿舍费用(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ExpensesController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeRoomExpense(1, req); + expect(service.purgeRoomExpense).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '费用管理', action: '永久删除宿舍费用', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/expenses/expenses.purge.spec.ts b/apps/server/src/expenses/expenses.purge.spec.ts new file mode 100644 index 0000000..4619e20 --- /dev/null +++ b/apps/server/src/expenses/expenses.purge.spec.ts @@ -0,0 +1,91 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; + +describe('ExpensesService purge', () => { + const billItemsRepo = { + count: jest.fn().mockResolvedValue(0), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue(billItemsRepo), + }; + const roomExpRepo = { + findOne: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn(), + }; + const personalExpRepo = { + findOne: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn(), + }; + + const createService = () => + new ExpensesService( + roomExpRepo as never, + personalExpRepo as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + new ExpenseOperationsService( + roomExpRepo as never, + personalExpRepo as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ), + ); + + beforeEach(() => { + jest.clearAllMocks(); + billItemsRepo.count.mockResolvedValue(0); + }); + + it('room expense purge rejects non-archived records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'active' }); + const service = createService(); + await expect(service.purgeRoomExpense(1)).rejects.toThrow( + new BadRequestException('仅已归档费用可以永久删除,请先归档'), + ); + expect(roomExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('room expense purge rejects billed records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' }); + billItemsRepo.count.mockResolvedValue(1); + const service = createService(); + await expect(service.purgeRoomExpense(1)).rejects.toThrow( + new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'), + ); + expect(roomExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('room expense purge deletes archived records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' }); + const service = createService(); + await expect(service.purgeRoomExpense(1)).resolves.toEqual({ + message: '已永久删除宿舍费用(不可恢复)', + }); + expect(roomExpRepo.delete).toHaveBeenCalledWith(1); + }); + + it('personal expense purge rejects records attached to a bill', async () => { + personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: 9 }); + const service = createService(); + await expect(service.purgePersonalExpense(1)).rejects.toThrow( + new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'), + ); + expect(personalExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('personal expense purge deletes archived records with no bill', async () => { + personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: null }); + const service = createService(); + await expect(service.purgePersonalExpense(1)).resolves.toEqual({ + message: '已永久删除个人费用(不可恢复)', + }); + expect(personalExpRepo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index f837a19..df8e329 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -11,8 +11,8 @@ import { BatchRoomExpenseDto, CreateStudentUtilityBillDto, } from './dto/expense.dto'; -import { RoomsService } from '../rooms/rooms.service'; import { BillsService } from '../bills/bills.service'; +import { ExpenseOperationsService } from './expense-operations.service'; @Injectable() @@ -24,6 +24,7 @@ export class ExpensesService { @InjectRepository(Student) private studentRepo: Repository, private billsService: BillsService, private dataSource: DataSource, + private operations: ExpenseOperationsService, ) {} async getFormLookups() { @@ -120,13 +121,18 @@ export class ExpensesService { const roomQb = this.roomExpRepo .createQueryBuilder('e') .leftJoin('e.room', 'room') - .select('e.id', 'id') - .addSelect('e.expenseType', 'expenseType') - .addSelect('e.amount', 'amount') - .addSelect('e.periodStart', 'periodStart') - .addSelect('e.periodEnd', 'periodEnd') - .addSelect('room.roomNumber', 'roomNumber') - .where('e.status = :status', { status: 'active' }); + .select('e.id', 'id'); + const roomExpenseSelects = [ + ['e.expenseType', 'expenseType'], + ['e.amount', 'amount'], + ['e.periodStart', 'periodStart'], + ['e.periodEnd', 'periodEnd'], + ['room.roomNumber', 'roomNumber'], + ] as const; + for (const [column, alias] of roomExpenseSelects) { + roomQb.addSelect(column, alias); + } + roomQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); } @@ -144,13 +150,18 @@ export class ExpensesService { const personalQb = this.personalExpRepo .createQueryBuilder('e') .leftJoin('e.student', 'student') - .select('e.id', 'id') - .addSelect('e.expenseType', 'expenseType') - .addSelect('e.amount', 'amount') - .addSelect('e.expenseDate', 'expenseDate') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .where('e.status = :status', { status: 'active' }); + .select('e.id', 'id'); + const personalExpenseSelects = [ + ['e.expenseType', 'expenseType'], + ['e.amount', 'amount'], + ['e.expenseDate', 'expenseDate'], + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ] as const; + for (const [column, alias] of personalExpenseSelects) { + personalQb.addSelect(column, alias); + } + personalQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { personalQb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', @@ -243,6 +254,48 @@ export class ExpensesService { return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped }; } + async purgeRoomExpense(id: number) { + const e = await this.roomExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { roomExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'); + await this.roomExpRepo.delete(id); + return { message: '已永久删除宿舍费用(不可恢复)' }; + } + + async batchPurgeRoomExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍费用'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { roomExpenseId: In(uniqueIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const e of existing) { + if (e.status !== 'archived') { + skipped.push(`记录${e.id}(未归档)`); + continue; + } + await this.roomExpRepo.delete(e.id); + deleted.push(e.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条宿舍费用(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async updateRoomExpense(id: number, dto: Partial) { const e = await this.roomExpRepo.findOne({ where: { id } }); const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } }); @@ -298,97 +351,37 @@ export class ExpensesService { // 个人附加费 async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { - this.assertPositiveAmount(dto.amount); - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); - return this.personalExpRepo.save(entity); + return this.operations.createPersonalExpense(dto, userId); } async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) { - const status = query?.status ?? 'active'; - if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); - const where: Record = { status }; - if (query?.studentId) where.studentId = query.studentId; - return this.personalExpRepo.find({ - where, - relations: ['student'], - order: { createdAt: 'DESC' }, - }); + return this.operations.findPersonalExpenses(query); } async deletePersonalExpense(id: number) { - const e = await this.personalExpRepo.findOne({ where: { id } }); - if (!e) throw new NotFoundException('费用记录不存在'); - if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单'); - if (e.status === 'archived') throw new BadRequestException('费用记录已归档'); - await this.personalExpRepo.update(id, { status: 'archived' }); - return { message: '已归档' }; + return this.operations.deletePersonalExpense(id); } async batchDeletePersonalExpenses(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录'); - const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); - if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); - if (existing.some((expense) => expense.billId)) { - throw new BadRequestException('选中记录包含已计入账单的个人费用'); - } - const result = await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: uniqueIds }) - .execute(); - return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; + return this.operations.batchDeletePersonalExpenses(ids); } async batchRestorePersonalExpenses(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('费用记录 ID 无效'); - } - const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); - if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); - const targets = existing.filter((expense) => expense.status === 'archived'); - if (targets.some((expense) => expense.billId)) { - throw new BadRequestException('选中记录包含已计入账单的个人费用'); - } + return this.operations.batchRestorePersonalExpenses(ids); + } - const targetIds = targets.map((expense) => expense.id); - const skipped = existing.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped }; + async purgePersonalExpense(id: number) { + return this.operations.purgePersonalExpense(id); + } + + async batchPurgePersonalExpenses(ids: number[]) { + return this.operations.batchPurgePersonalExpenses(ids); } async updatePersonalExpense(id: number, dto: Partial) { - const e = await this.personalExpRepo.findOne({ where: { id } }); - if (!e) throw new NotFoundException('费用记录不存在'); - if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单'); - if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); - if (dto.studentId !== undefined && dto.studentId !== e.studentId) { - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - } - Object.assign(e, dto); - return this.personalExpRepo.save(e); + return this.operations.updatePersonalExpense(id, dto); } - /** - * 水电费Excel批量导入 - * Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额 - * 时间格式: "2026-01-21 - 2026-02-08" - */ async batchImportUtilityExpenses( rows: { periodStr: string; @@ -401,156 +394,9 @@ export class ExpensesService { }[], userId?: number, ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; - - if (!row.roomNumber?.trim()) { - skipped++; - continue; - } - - try { - // 查找或创建宿舍 - let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (!room) { - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - room = await this.roomRepo.save( - this.roomRepo.create({ - roomNumber: row.roomNumber.trim(), - building: parsed.building || undefined, - floor: parsed.floor || undefined, - capacity: parsed.capacity || 4, - roomType: parsed.roomType || undefined, - }), - ); - } - - // 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08" - let periodStart = ''; - let periodEnd = ''; - if (row.periodStr) { - // 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符) - let parts = row.periodStr.split(/\s+[-~~]\s+/); - if (parts.length < 2) { - // 回退:尝试用正则提取 YYYY-MM-DD 格式的日期 - const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g); - if (dateMatches && dateMatches.length >= 2) { - parts = [dateMatches[0], dateMatches[1]]; - } - } - if (parts.length >= 2) { - periodStart = this.normalizeDate(parts[0].trim()); - periodEnd = this.normalizeDate(parts[1].trim()); - } - } - if (!periodStart || !periodEnd) { - errors.push(`第${rowNum}行: 时间格式无法解析 "${row.periodStr}"`); - skipped++; - continue; - } - if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { - errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`); - skipped++; - continue; - } - - // 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失, - // 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。 - if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) { - errors.push( - `第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`, - ); - skipped++; - continue; - } - - const existing = await this.roomExpRepo.find({ - where: [ - { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` }, - { importKey: `${room.id}:${periodStart}:${periodEnd}:water` }, - ], - }); - const byType = new Map(existing.map((expense) => [expense.expenseType, expense])); - - let savedAny = false; - // 导入电费 - if (row.electricityFee > 0) { - const expense = byType.get('electricity') || this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'electricity', - periodStart, - periodEnd, - importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`, - }); - if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { - throw new BadRequestException('该周期电费已计入账单,不能覆盖'); - } - expense.amount = row.electricityFee; - expense.description = `电量${row.electricityAmount}kWh`; - expense.recordedBy = userId!; - await this.roomExpRepo.save(expense); - savedAny = true; - } - - // 导入水费 - if (row.waterFee > 0) { - const expense = byType.get('water') || this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'water', - periodStart, - periodEnd, - importKey: `${room.id}:${periodStart}:${periodEnd}:water`, - }); - if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { - throw new BadRequestException('该周期水费已计入账单,不能覆盖'); - } - expense.amount = row.waterFee; - expense.description = `用水${row.waterAmount}吨`; - expense.recordedBy = userId!; - await this.roomExpRepo.save(expense); - savedAny = true; - } - - if (savedAny) imported++; - else { - skipped++; - errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); - } - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); - skipped++; - } - } - - return { - message: - imported > 0 - ? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped} 条` : ''}` - : `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`, - imported, - skipped, - errors: errors.length > 0 ? errors : undefined, - }; + return this.operations.batchImportUtilityExpenses(rows, userId); } - /** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */ - private normalizeDate(s: string): string { - if (!s) return ''; - if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; - const m = s.match(/(\d{4})[\-\/.](\d{1,2})[\-\/.](\d{1,2})/); - if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`; - return s; - } - - /** - * 个人附加费Excel批量导入 - * Excel格式: 学生姓名|费用类型|金额|费用日期|说明 - */ async batchImportPersonalExpenses( rows: { studentName: string; @@ -561,83 +407,6 @@ export class ExpensesService { }[], userId?: number, ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; - - if (!row.studentName?.trim()) { - skipped++; - continue; - } - - try { - // 查找学生 - const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } }); - if (!student) { - errors.push(`第${rowNum}行: 学生"${row.studentName}"未找到`); - skipped++; - continue; - } - - // 解析费用类型 - const expenseType = row.expenseType?.trim() || ''; - if (!expenseType) { - errors.push(`第${rowNum}行: 费用类型不能为空`); - skipped++; - continue; - } - - // 解析日期 - let expenseDate = row.expenseDate?.trim() || ''; - if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { - // 尝试从各种格式解析 - const dateMatch = expenseDate.match(/(\d{4})[\-\/](\d{1,2})[\-\/](\d{1,2})/); - if (dateMatch) { - expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`; - } else { - errors.push(`第${rowNum}行: 日期格式"${row.expenseDate}"无效,需要YYYY-MM-DD`); - skipped++; - continue; - } - } - - // 校验金额 - try { - this.assertPositiveAmount(row.amount); - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); - skipped++; - continue; - } - - await this.personalExpRepo.save( - this.personalExpRepo.create({ - studentId: student.id, - expenseType, - amount: row.amount, - expenseDate, - description: row.description || undefined, - recordedBy: userId, - }), - ); - - imported++; - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); - skipped++; - } - } - - return { - message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped} 条`, - imported, - skipped, - errors: errors.length > 0 ? errors : undefined, - }; + return this.operations.batchImportPersonalExpenses(rows, userId); } } diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 910d5c4..a291654 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { DINGTALK_OAUTH_TOKEN_URL } from '../endpoints'; import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity'; import { ThirdConfigBaseDTO, @@ -208,7 +209,7 @@ export class IntegrationConfigService { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { + const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appKey, appSecret }), diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index e98d30c..433c4b4 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同) /** * 钉钉集成服务 — 对齐 gongxue-dorm-sys * @@ -12,203 +13,52 @@ import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { syncDingTalkStudents } from './dingtalk-student-sync'; import { IntegrationConfigService } from './config/integration-config.service'; +import { DINGTALK_OAUTH_TOKEN_URL } from './endpoints'; +import { isDingTalkUserListResponse } from './dingtalk.types'; +import type { + DingTalkCredentials, + DingTalkDeptGetResponse, + DingTalkDeptListResponse, + DingTalkServiceContext, + DingTalkUserListResponse, + OrgDeptNode, + OrgDeptNodeWithUsers, +} from './dingtalk.types'; +import { DingTalkAttendanceClient } from './dingtalk.attendance'; +import { DingTalkShiftClient } from './dingtalk.shifts'; +import { DingTalkGroupClient } from './dingtalk.groups'; +import { DingTalkScheduleClient } from './dingtalk.schedules'; -// ── Types ── - - -interface DingTalkCredentials { - appKey: string; - appSecret: string; -} - -interface DingTalkUserListResponse { - errcode: number; - errmsg: string; - result: { - has_more: boolean; - next_cursor?: number; - list: Array<{ - userid: string; - name: string; - mobile: string; - dept_id_list: number[]; - }>; - }; -} - - -function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse { - if (!value || typeof value !== 'object' || !('errcode' in value)) return false; - if (typeof value.errcode !== 'number') return false; - if ('errmsg' in value && typeof value.errmsg !== 'string') return false; - if (!('result' in value) || !value.result || typeof value.result !== 'object') { - return value.errcode !== 0; - } - if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false; - if (!('list' in value.result) || !Array.isArray(value.result.list)) return false; - return value.result.list.every( - (item) => - item && - typeof item === 'object' && - 'userid' in item && - typeof item.userid === 'string' && - 'name' in item && - typeof item.name === 'string' && - 'mobile' in item && - typeof item.mobile === 'string' && - 'dept_id_list' in item && - Array.isArray(item.dept_id_list) && - item.dept_id_list.every((id) => typeof id === 'number'), - ); -} - -/** 钉钉打卡结果 — 对齐 dws attendance check result */ -export interface DingTalkAttendanceResult { - userId: string; - userName: string; - workDate: string; - timeResult: string; - locationResult: string; - planCheckTime: string; - actualCheckTime: string; - checkId: string; - checkType: string; - /** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */ - sourceType: string; - /** 部分钉钉租户会额外返回考勤机名称或编号。 */ - deviceName?: string; - deviceId?: string; -} - -// ── 组织架构 API 类型 ── - -interface DingTalkDeptListResponse { - errcode: number; - result?: Array<{ dept_id: number; name: string; parent_id: number }>; -} - -interface DingTalkDeptGetResponse { - errcode: number; - result?: { name: string; parent_id: number }; -} - -export interface OrgDeptNode { - id: number; - name: string; - parentId: number; - children: OrgDeptNode[]; -} - -export interface OrgDeptNodeWithUsers extends OrgDeptNode { - users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; -} - -// ── 考勤排班 API 类型 ── - -/** 班次卡段打卡时间 */ -export interface DingTalkShiftTime { - check_type: 'OnDuty' | 'OffDuty'; - across: number; - check_time: string; - begin_min?: number; - end_min?: number; - free_check?: boolean; -} - -/** 班次卡段 */ -export interface DingTalkShiftSection { - times: DingTalkShiftTime[]; -} - -/** 班次配置 */ -export interface DingTalkShiftSetting { - is_flexible?: boolean; - serious_late_minutes?: number; - absenteeism_late_minutes?: number; -} - -/** 创建/修改班次参数 */ -export interface DingTalkShiftParams { - id?: number; - name: string; - owner?: string; - sections: DingTalkShiftSection[]; - setting?: DingTalkShiftSetting; -} - -/** 班次摘要(查询返回) */ -export interface DingTalkShiftSummary { - id: number; - name: string; -} - -/** 考勤组成员 */ -export interface DingTalkGroupMember { - role: string; - type: 'StaffMember' | 'DeptMember'; - user_id: string; -} - -/** 创建考勤组参数 */ -export interface DingTalkGroupParams { - name: string; - type: 'TURN'; - owner: string; - members: DingTalkGroupMember[]; - shift_ids?: number[]; - enable_emp_select_class?: boolean; - disable_check_without_schedule?: boolean; - disable_check_when_rest?: boolean; - /** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */ - attendance_machine_only?: boolean; -} - -/** 修改考勤组参数 */ -export interface DingTalkGroupUpdateParams extends DingTalkGroupParams { - id: number; -} - -/** 考勤组摘要(查询返回) */ -export interface DingTalkGroupSummary { - group_id: number; - group_name: string; - type: string; - member_count: number; -} - -/** 排班参数(单条) */ -export interface DingTalkScheduleItem { - userid: string; - work_date: number; - shift_id: number; - is_rest?: boolean; -} - -/** 排班查询结果 */ -export interface DingTalkScheduleResult { - userid: string; - work_date: string; - shift_id: number; - is_rest: string; - check_type: string; - plan_check_time: string; - group_id: number; - id: number; -} - +export type { + DingTalkAttendanceResult, + DingTalkGroupParams, + DingTalkGroupSummary, + DingTalkGroupUpdateParams, + DingTalkScheduleItem, + DingTalkScheduleResult, + DingTalkShiftParams, + DingTalkShiftSummary, + OrgDeptNode, + OrgDeptNodeWithUsers, +} from './dingtalk.types'; @Injectable() -export class DingTalkService { - private readonly logger = new Logger(DingTalkService.name); - private accessToken: string | null = null; - private accessTokenCredentialKey: string | null = null; - private tokenExpiresAt = 0; - private apiRequestCount = 0; +export class DingTalkService implements DingTalkServiceContext { + accessToken: string | null = null; + accessTokenCredentialKey: string | null = null; + tokenExpiresAt = 0; + apiRequestCount = 0; + readonly logger = new Logger(DingTalkService.name); /** 钉钉 API 限流:每秒最多 20 次 */ private static readonly RATE_LIMIT = 20; private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; + private attendanceClient?: DingTalkAttendanceClient; + private shiftClient?: DingTalkShiftClient; + private groupClient?: DingTalkGroupClient; + private scheduleClient?: DingTalkScheduleClient; + constructor( @InjectRepository(Student) private readonly studentRepo: Repository, @@ -218,7 +68,27 @@ export class DingTalkService { private readonly dataSource?: DataSource, ) {} - private async getCredentials(): Promise { + private get attendance(): DingTalkAttendanceClient { + if (!this.attendanceClient) this.attendanceClient = new DingTalkAttendanceClient(this); + return this.attendanceClient; + } + + private get shifts(): DingTalkShiftClient { + if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this); + return this.shiftClient; + } + + private get groups(): DingTalkGroupClient { + if (!this.groupClient) this.groupClient = new DingTalkGroupClient(this); + return this.groupClient; + } + + private get schedules(): DingTalkScheduleClient { + if (!this.scheduleClient) this.scheduleClient = new DingTalkScheduleClient(this); + return this.scheduleClient; + } + + async getCredentials(): Promise { const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; @@ -235,7 +105,7 @@ export class DingTalkService { return null; } - private async isConfigured(): Promise { + async isConfigured(): Promise { return !!(await this.getCredentials()); } @@ -243,7 +113,7 @@ export class DingTalkService { // Token — 对齐 gongxue-dorm-sys getAccessToken // ═══════════════════════════════════════════ - private async getAccessToken(): Promise { + async getAccessToken(): Promise { const credentials = await this.getCredentials(); if (!credentials) { throw new Error('DingTalk not configured'); @@ -258,7 +128,7 @@ export class DingTalkService { return this.accessToken; } - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { + const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), @@ -285,7 +155,7 @@ export class DingTalkService { // Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment // ═══════════════════════════════════════════ - private async getDeptUsers( + async getDeptUsers( token: string, deptId: number, ): Promise> { @@ -482,445 +352,61 @@ export class DingTalkService { return [attachUsers(deptTree)]; } - // ═══════════════════════════════════════════ // Rate limiting — 对齐 gongxue-dorm-sys // ═══════════════════════════════════════════ - private async rateLimit(): Promise { + async rateLimit(): Promise { await this.sleep(DingTalkService.MIN_INTERVAL); this.apiRequestCount++; } - // ═══════════════════════════════════════════ - // 考勤打卡结果 — 对齐 dws attendance check result - // ═══════════════════════════════════════════ - - async fetchAttendanceResults(params: { - startDate: string; - endDate: string; - userIds?: string[]; - }): Promise { - if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); - if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); - if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); - const token = await this.getAccessToken(); - - const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`; - const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`; - - const body: Record = { - checkDateFrom: dateFrom, - checkDateTo: dateTo, - }; - body.userIds = params.userIds; - - const res = await fetch( - `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = await res.json() as { - errcode: number; errmsg: string; - recordresult?: Array<{ - id: number; userId: string; workDate: number; - userCheckTime: number; sourceType: string; - checkType?: string; timeResult?: string; - locationResult?: string; locationMethod?: string; - userAddress?: string; userLongitude?: number; userLatitude?: number; - deviceName?: string; deviceId?: string | number; deviceSN?: string | number; - attendanceMachineName?: string; attendanceMachineId?: string | number; - }>; - }; - if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); - - const records = data.recordresult ?? []; - - return records.map((r) => ({ - userId: r.userId, - userName: '', - workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10), - timeResult: r.timeResult ?? r.sourceType ?? '', - locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '', - planCheckTime: '', - actualCheckTime: new Date(r.userCheckTime).toISOString(), - checkId: String(r.id), - checkType: r.checkType ?? '', - sourceType: r.sourceType ?? '', - deviceName: r.deviceName ?? r.attendanceMachineName, - deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined, - })); + async fetchAttendanceResults( + ...args: Parameters + ) { + return this.attendance.fetchAttendanceResults(...args); } - // ═══════════════════════════════════════════ - // 考勤排班 — 班次管理 - // ═══════════════════════════════════════════ - - /** 创建或修改班次。id 不传=创建,传了=修改 */ - async upsertShift(params: DingTalkShiftParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const body: Record = { - op_user_id: params.owner || 'manager', - shift: { - name: params.name, - owner: params.owner, - sections: params.sections.map((s) => ({ - times: s.times.map((t) => ({ - check_type: t.check_type, - across: t.across, - check_time: t.check_time, - begin_min: t.begin_min ?? -1, - end_min: t.end_min ?? -1, - free_check: t.free_check ?? false, - })), - })), - setting: params.setting - ? { - is_flexible: params.setting.is_flexible ?? false, - serious_late_minutes: params.setting.serious_late_minutes ?? -1, - absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1, - } - : undefined, - }, - }; - if (params.id) (body.shift as Record).id = params.id; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { id: number; name: string }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`); - return data.result!.id; + async upsertShift(...args: Parameters) { + return this.shifts.upsertShift(...args); } - /** 查询所有班次摘要(每页最多200条) */ - async queryShifts(opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const all: DingTalkShiftSummary[] = []; - let cursor = 0; - let hasMore = true; - - while (hasMore) { - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: opUserId, cursor }), - }, - ); - const data = (await res.json()) as { - errcode: number; - errmsg: string; - result?: { - cursor?: number; - has_more?: boolean; - result?: Array<{ id: number; name: string }>; - }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); - } - - const page = data.result; - all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name }))); - hasMore = page?.has_more ?? false; - if (hasMore) { - if (page?.cursor === undefined || page.cursor === cursor) { - throw new Error('钉钉查询班次失败: 分页游标无效'); - } - cursor = page.cursor; - } - } - - return all; + async queryShifts(...args: Parameters) { + return this.shifts.queryShifts(...args); } - - // ═══════════════════════════════════════════ - // 考勤排班 — 考勤组管理 - // ═══════════════════════════════════════════ - - /** 创建排班制考勤组 */ - async createAttendanceGroup(params: DingTalkGroupParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const topGroup = this.buildAttendanceGroupBody(params); - - const body = { op_user_id: params.owner, top_group: topGroup }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { id: number }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`); - return data.result!.id; + async createAttendanceGroup( + ...args: Parameters + ) { + return this.groups.createAttendanceGroup(...args); } - /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ - async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }), - }, - ); - const data = (await res.json()) as { - errcode?: number; - errmsg?: string; - success?: boolean; - message?: string; - }; - const succeeded = data.success === true || data.errcode === 0; - if (!succeeded) { - throw new Error( - `钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` + - `(code=${data.errcode ?? 'unknown'})`, - ); - } - this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`); + async updateAttendanceGroup( + ...args: Parameters + ) { + return this.groups.updateAttendanceGroup(...args); } - private buildAttendanceGroupBody(params: DingTalkGroupParams): Record { - const machineOnly = params.attendance_machine_only ?? false; - const topGroup: Record = { - name: params.name, - type: params.type, - owner: params.owner, - members: params.members.map((m) => ({ - role: m.role, - type: m.type, - user_id: m.user_id, - })), - enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true), - disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false), - disable_check_when_rest: params.disable_check_when_rest ?? true, - }; - if (params.shift_ids?.length) { - topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id })); - } - if (machineOnly) { - Object.assign(topGroup, { - enable_outside_check: false, - enable_position_ble: false, - positions: [], - wifis: [], - }); - } - return topGroup; + async queryAttendanceGroups( + ...args: Parameters + ) { + return this.groups.queryAttendanceGroups(...args); } - /** 查询所有考勤组摘要(分页,每页10条) */ - async queryAttendanceGroups(_opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const all: DingTalkGroupSummary[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ offset, size: 10 }), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { - has_more: boolean; - groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>; - }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`); - } - if (data.result?.groups) { - all.push(...data.result.groups.map((g) => ({ - group_id: g.group_id, - group_name: g.group_name, - type: g.type, - member_count: g.member_count, - }))); - } - hasMore = data.result?.has_more ?? false; - offset += 10; - } - return all; + async deleteAttendanceGroup( + ...args: Parameters + ) { + return this.groups.deleteAttendanceGroup(...args); } - async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - await this.rateLimit(); - const keyResponse = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }), - }, - ); - const keyData = await keyResponse.json() as { - errcode: number; - errmsg: string; - result?: string; - }; - if (keyData.errcode !== 0 || !keyData.result) { - throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`); - } - - await this.rateLimit(); - const deleteResponse = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }), - }, - ); - const deleteData = await deleteResponse.json() as { - errcode: number; - errmsg: string; - success?: boolean; - }; - if (deleteData.errcode !== 0 || deleteData.success !== true) { - throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`); - } + async scheduleUsers(...args: Parameters) { + return this.schedules.scheduleUsers(...args); } - - // ═══════════════════════════════════════════ - // 考勤排班 — 排班分配 - // ═══════════════════════════════════════════ - - /** 批量排班(单次最多200条) */ - async scheduleUsers( - groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', - ): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - if (schedules.length === 0) return; - if (schedules.length > 200) { - throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); - } - - const token = await this.getAccessToken(); - const body = { - op_user_id: opUserId, - group_id: groupId, - schedules: schedules.map((s) => ({ - userid: s.userid, - work_date: s.work_date, - shift_id: s.shift_id, - is_rest: s.is_rest ?? false, - })), - }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length} 条`); - } - - /** 查询指定用户的排班信息(7天内,最多50人) */ async queryScheduleByUsers( - userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', - ): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - op_user_id: opUserId, - userids: userIds.join(','), - from_date_time: fromDate, - to_date_time: toDate, - }), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: Array<{ - userid: string; work_date: string; shift_id: number; - is_rest: string; check_type: string; plan_check_time: string; - group_id: number; id: number; - }>; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`); - } - return (data.result ?? []).map((r) => ({ - userid: r.userid, - work_date: r.work_date, - shift_id: r.shift_id, - is_rest: r.is_rest, - check_type: r.check_type, - plan_check_time: r.plan_check_time, - group_id: r.group_id, - id: r.id, - })); + ...args: Parameters + ) { + return this.schedules.queryScheduleByUsers(...args); } private sleep(ms: number): Promise { diff --git a/apps/server/src/integration/jinshuju-student-sync.ts b/apps/server/src/integration/jinshuju-student-sync.ts index d37ef09..eed8dee 100644 --- a/apps/server/src/integration/jinshuju-student-sync.ts +++ b/apps/server/src/integration/jinshuju-student-sync.ts @@ -23,7 +23,6 @@ export async function syncJinshujuStudents( manager: EntityManager, entries: JinshujuEntry[], ): Promise { - // Extract name/phone from entries interface ParsedEntry { serialNumber: number; name: string; @@ -96,7 +95,6 @@ export async function syncJinshujuStudents( toCreate.push({ name: p.name, phone: p.phone }); } - // Create new students let created = 0; if (toCreate.length > 0) { const host = await manager.findOne(Organization, { where: { isHost: true, status: 'active' } }); diff --git a/apps/server/src/integration/jinshuju.service.ts b/apps/server/src/integration/jinshuju.service.ts index 4951fab..d8861d8 100644 --- a/apps/server/src/integration/jinshuju.service.ts +++ b/apps/server/src/integration/jinshuju.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { JINSHUJU_API_BASE } from './endpoints'; export interface JinshujuEntry { serial_number: number; @@ -15,6 +16,7 @@ export interface JinshujuEntriesResponse { next: number | null; } +// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 JinshujuMatchModal 的 API 契约保持一致 export interface JinshujuFormField { key: string; label: string; @@ -29,7 +31,6 @@ interface JinshujuFormResponse { @Injectable() export class JinshujuService { private readonly logger = new Logger(JinshujuService.name); - private static readonly BASE = 'https://jinshuju.net/api/v1'; private getAuthorization(apiKey: string, apiSecret: string): string { return `Basic ${Buffer.from(`${apiKey}:${apiSecret}`).toString('base64')}`; @@ -41,7 +42,7 @@ export class JinshujuService { formToken: string, ): Promise<{ name: string; fields: JinshujuFormField[] }> { const response = await fetch( - `${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}`, + `${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}`, { headers: { Authorization: this.getAuthorization(apiKey, apiSecret), @@ -74,7 +75,7 @@ export class JinshujuService { let next: number | null | undefined = undefined; do { - const url = new URL(`${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}/entries`); + const url = new URL(`${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}/entries`); if (next) url.searchParams.set('next', String(next)); this.logger.log(`Fetching Jinshuju entries: ${url.toString().replace(/api_key=[^&]+/, 'api_key=***')}`); diff --git a/apps/server/src/integration/wecom.service.ts b/apps/server/src/integration/wecom.service.ts index f8bcfbd..bc59189 100644 --- a/apps/server/src/integration/wecom.service.ts +++ b/apps/server/src/integration/wecom.service.ts @@ -2,6 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../entities/user.entity'; +import { + WECOM_API_BASE, + WECOM_DEPARTMENT_PATH, + WECOM_TOKEN_PATH, + WECOM_USER_PATH, +} from './endpoints'; interface WeComTokenResponse { errcode: number; @@ -48,7 +54,7 @@ export class WeComService { } const corpId = process.env.WECOM_CORP_ID!; const corpSecret = process.env.WECOM_CORP_SECRET!; - const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`; + const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`; const res = await fetch(url); const body: WeComTokenResponse = await res.json(); if (body.errcode !== 0) { @@ -64,7 +70,7 @@ export class WeComService { parentId = 1, ): Promise> { const all: WeComDeptListResponse['department'] = []; - const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`; + const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`; const res = await fetch(url); const body: WeComDeptListResponse = await res.json(); if (body.errcode !== 0) { @@ -85,7 +91,7 @@ export class WeComService { token: string, deptId: number, ): Promise> { - const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`; + const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`; const res = await fetch(url); const body: WeComUserListResponse = await res.json(); if (body.errcode !== 0) { diff --git a/apps/server/src/occupancies/occupancies.controller.spec.ts b/apps/server/src/occupancies/occupancies.controller.spec.ts index 605d00b..d8e16bb 100644 --- a/apps/server/src/occupancies/occupancies.controller.spec.ts +++ b/apps/server/src/occupancies/occupancies.controller.spec.ts @@ -23,4 +23,13 @@ describe('OccupanciesController permissions', () => { Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate), ).toEqual(['occupancy:view']); }); + + it('requires occupancy:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.purge)).toEqual([ + 'occupancy:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchPurge), + ).toEqual(['occupancy:purge']); + }); }); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 5255169..ee49fa3 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -27,6 +27,7 @@ import { NotificationType } from '../entities/notification.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; @@ -66,16 +67,9 @@ export class OccupanciesController { @RequirePermission('occupancy:delete') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量恢复入住记录', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -83,16 +77,9 @@ export class OccupanciesController { @Post('batch-check-out') @RequirePermission('occupancy:checkout') async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCheckOut(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量退宿', - detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, }); return result; } @@ -100,18 +87,9 @@ export class OccupanciesController { @Post('check-in') @RequirePermission('occupancy:checkin') async checkIn(@Body() dto: CheckInDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.checkIn(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '办理入住', - targetId: result.id, - targetType: 'occupancy', - detail: `学生${dto.studentId} 入住房间${dto.roomId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, }); // Send check_in notification try { @@ -133,17 +111,9 @@ export class OccupanciesController { @Put(':id/check-out') @RequirePermission('occupancy:checkout') async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.checkOut(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '办理退宿', - targetId: +id, - targetType: 'occupancy', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy', }); // Send check_out notification try { @@ -165,18 +135,9 @@ export class OccupanciesController { @Put(':id/transfer') @RequirePermission('occupancy:transfer') async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.transferRoom(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '调换宿舍', - targetId: +id, - targetType: 'occupancy', - detail: `换到房间${dto.newRoomId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, }); return result; } @@ -184,17 +145,9 @@ export class OccupanciesController { @Delete(':id') @RequirePermission('occupancy:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '归档入住记录', - targetId: +id, - targetType: 'occupancy', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy', }); return result; } @@ -202,16 +155,29 @@ export class OccupanciesController { @Post('batch-delete') @RequirePermission('occupancy:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量归档入住记录', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('occupancy:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('occupancy:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -298,7 +264,7 @@ export class OccupanciesController { const { ipAddress, userAgent } = extractRequestInfo(req); if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { diff --git a/apps/server/src/occupancies/occupancies.module.ts b/apps/server/src/occupancies/occupancies.module.ts index fab0445..c54bb76 100644 --- a/apps/server/src/occupancies/occupancies.module.ts +++ b/apps/server/src/occupancies/occupancies.module.ts @@ -7,19 +7,31 @@ import { Deposit } from '../entities/deposit.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; +import { OccupancyImportService } from './occupancy-import.service'; import { OccupanciesController } from './occupancies.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker, Organization]), + TypeOrmModule.forFeature([ + Occupancy, + Room, + Student, + Deposit, + Bed, + Locker, + Organization, + RoomInspectionDetail, + ]), OperationLogsModule, NotificationsModule, ], controllers: [OccupanciesController], - providers: [OccupanciesService], + providers: [OccupanciesService, OccupancyOperationsService, OccupancyImportService], exports: [OccupanciesService], }) export class OccupanciesModule {} diff --git a/apps/server/src/occupancies/occupancies.purge.spec.ts b/apps/server/src/occupancies/occupancies.purge.spec.ts new file mode 100644 index 0000000..8f6a14e --- /dev/null +++ b/apps/server/src/occupancies/occupancies.purge.spec.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; +import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; + +describe('OccupanciesService.purge', () => { + const createService = (overrides?: { + occupancy?: Record; + detailCount?: number; + }) => { + const occ = { id: 1, status: 'archived', student: { name: '张三' }, ...overrides?.occupancy }; + const repo = { + findOne: jest.fn().mockResolvedValue(occ), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([occ]), + }; + const inspectionDetailRepo = { + count: jest.fn().mockResolvedValue(overrides?.detailCount ?? 0), + }; + const operations = new OccupancyOperationsService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + inspectionDetailRepo as never, + ); + const service = new OccupanciesService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + inspectionDetailRepo as never, + operations, + ); + return { service, repo, inspectionDetailRepo }; + }; + + it('rejects occupancies that are not archived', async () => { + const { service, repo } = createService({ occupancy: { status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档入住记录可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects occupancies referenced by inspection details', async () => { + const { service, repo } = createService({ detailCount: 1 }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该入住记录已被查寝记录引用,无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived occupancy with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除入住记录(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge skips referenced records', async () => { + const { service, repo, inspectionDetailRepo } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, status: 'archived', student: { name: '甲' } }, + { id: 2, status: 'archived', student: { name: '乙' } }, + ]); + inspectionDetailRepo.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0); + const result = await service.batchPurge([1, 2]); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 79b2e18..372c435 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -1,5 +1,6 @@ import { Repository, DataSource } from 'typeorm'; import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; @@ -108,6 +109,17 @@ describe('OccupanciesService — responsible organization', () => { student: { id: 3, gender: '男', organizationId: 7 }, bed: { id: 4, roomId: 2, status: 'available' }, }); + const operations = new OccupancyOperationsService( + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + createTransactionDataSource(manager), + {} as Repository, + ); const service = new OccupanciesService( {} as Repository, {} as Repository, @@ -117,6 +129,8 @@ describe('OccupanciesService — responsible organization', () => { {} as Repository, {} as Repository, createTransactionDataSource(manager), + {} as Repository, + operations, ); await service.checkIn({ @@ -151,6 +165,18 @@ describe('OccupanciesService — manual check-in deposit', () => { {} as Repository, {} as Repository, createTransactionDataSource(manager), + {} as Repository, + new OccupancyOperationsService( + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + createTransactionDataSource(manager), + {} as Repository, + ), ), manager, }; diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 2dfdae1..6ba2d8e 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -1,16 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - Repository, - DataSource, - IsNull, - Between, - LessThanOrEqual, - MoreThanOrEqual, - In, - SelectQueryBuilder, - ObjectLiteral, -} from 'typeorm'; +import { Repository, DataSource, IsNull, SelectQueryBuilder, ObjectLiteral } from 'typeorm'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; @@ -18,10 +8,10 @@ import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { Deposit } from '../entities/deposit.entity'; import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; -import { RoomsService } from '../rooms/rooms.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; -class ImportRowSkipped extends Error {} @Injectable() export class OccupanciesService { @@ -34,21 +24,37 @@ export class OccupanciesService { @InjectRepository(Locker) private lockerRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, private dataSource: DataSource, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, + @Optional() private operations?: OccupancyOperationsService, ) {} - private withPessimisticWriteLock( - qb: SelectQueryBuilder, - ): SelectQueryBuilder { - const type = this.dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); + private get ops(): OccupancyOperationsService { + if (!this.operations) { + this.operations = new OccupancyOperationsService( + this.repo, + this.roomRepo, + this.studentRepo, + this.depositRepo, + this.bedRepo, + this.lockerRepo, + this.organizationRepo, + this.dataSource, + this.inspectionDetailRepo, + ); } - return qb; + return this.operations; } - async findAll(query?: { roomId?: number; studentId?: number; active?: boolean; status?: 'active' | 'archived' }) { + async findAll(query?: { + roomId?: number; + studentId?: number; + active?: boolean; + status?: 'active' | 'archived'; + }) { const status = query?.status ?? 'active'; - if (status !== 'active' && status !== 'archived') throw new BadRequestException('入住记录状态无效'); + if (status !== 'active' && status !== 'archived') + throw new BadRequestException('入住记录状态无效'); const qb = this.repo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') @@ -126,7 +132,8 @@ export class OccupanciesService { ); if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' }); if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' }); - if (count + 1 >= (room.capacity ?? 0)) await manager.update(Room, room.id, { status: 'full' }); + if (count + 1 >= (room.capacity ?? 0)) + await manager.update(Room, room.id, { status: 'full' }); if (dto.collectDeposit) { let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } }); if (deposit) { @@ -153,228 +160,65 @@ export class OccupanciesService { }); } + private withPessimisticWriteLock( + qb: SelectQueryBuilder, + ): SelectQueryBuilder { + const type = this.dataSource.options.type; + if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { + return qb.setLock('pessimistic_write'); + } + return qb; + } + + private normalizePositiveMoney(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException(`${label}必须为非负数字`); + } + return Math.round(value * 100) / 100; + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) { + throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`); + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + if (end && start > end) throw new BadRequestException(message); + } + async checkOut(occupancyId: number, dto: CheckOutDto) { - return this.dataSource.transaction(async (manager) => { - const occ = await this.withPessimisticWriteLock( - manager - .createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }), - ).getOne(); - if (!occ) throw new NotFoundException('入住记录不存在'); - if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); - this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder( - occ.billingStartDate || occ.checkInDate, - dto.billingEndDate || dto.checkOutDate, - '计费截止日不能早于计费起始日', - ); - occ.checkOutDate = dto.checkOutDate; - occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; - occ.checkOutReason = dto.checkOutReason || ''; - await manager.save(occ); - if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); - if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); - await manager.update(Room, occ.roomId, { status: 'available' }); - return occ; - }); + return this.ops.checkOut(occupancyId, dto); } async transferRoom(occupancyId: number, dto: TransferRoomDto) { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - await runner.startTransaction(); - try { - const oldOcc = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }), - ).getOne(); - if (!oldOcc) throw new NotFoundException('入住记录不存在'); - if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); - if (oldOcc.roomId === dto.newRoomId) - throw new BadRequestException('目标宿舍不能与当前宿舍相同'); - this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期'); - this.assertDateOrder( - oldOcc.billingStartDate || oldOcc.checkInDate, - dto.oldBillingEndDate || dto.transferDate, - '原宿舍计费截止日不能早于计费起始日', - ); - - // 退旧房 - oldOcc.checkOutDate = dto.transferDate; - oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate; - oldOcc.checkOutReason = dto.reason || '换房'; - await runner.manager.save(oldOcc); - // 释放旧床位/柜子 - if (oldOcc.bedId) { - await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' }); - } - if (oldOcc.lockerId) { - await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); - } - await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); - // 检查新房容量 - const newRoom = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Room, 'room') - .where('room.id = :roomId', { roomId: dto.newRoomId }), - ).getOne(); - if (!newRoom) throw new NotFoundException('目标宿舍不存在'); - if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { - throw new BadRequestException('目标宿舍当前不可入住'); - } - const count = await runner.manager.count(Occupancy, { - where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, - }); - if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满'); - - // 新床位校验 - if (dto.newBedId) { - const newBed = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Bed, 'bed') - .where('bed.id = :bedId AND bed.roomId = :roomId', { - bedId: dto.newBedId, - roomId: dto.newRoomId, - }), - ).getOne(); - if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); - if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); - } - if (dto.newLockerId) { - const newLocker = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Locker, 'locker') - .where('locker.id = :lockerId AND locker.roomId = :roomId', { - lockerId: dto.newLockerId, - roomId: dto.newRoomId, - }), - ).getOne(); - if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); - if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); - } - - // 计算新房计费起始日:默认为换房日期次日 - const transferDate = new Date(dto.transferDate); - const nextDay = new Date(transferDate); - nextDay.setDate(nextDay.getDate() + 1); - const defaultBillingStart = nextDay.toISOString().split('T')[0]; - this.assertDateOrder( - dto.transferDate, - dto.newBillingStartDate || defaultBillingStart, - '新宿舍计费起始日不能早于换房日期', - ); - - // 入住新房 - const newOcc = runner.manager.create(Occupancy, { - studentId: oldOcc.studentId, - roomId: dto.newRoomId, - checkInDate: dto.transferDate, - billingStartDate: dto.newBillingStartDate || defaultBillingStart, - stayType: oldOcc.stayType, - responsibleOrganizationId: oldOcc.responsibleOrganizationId, - notes: `从${oldOcc.roomId}号房换入`, - bedId: dto.newBedId, - lockerId: dto.newLockerId, - }); - await runner.manager.save(newOcc); - - // 更新新床位/柜子状态 - if (dto.newBedId) { - await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' }); - } - if (dto.newLockerId) { - await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); - } - - if (count + 1 >= (newRoom.capacity ?? 0)) { - await runner.manager.update(Room, newRoom.id, { status: 'full' }); - } - - await runner.commitTransaction(); - return { oldOccupancy: oldOcc, newOccupancy: newOcc }; - } catch (err) { - await runner.rollbackTransaction(); - throw err; - } finally { - await runner.release(); - } + return this.ops.transferRoom(occupancyId, dto); } - // 获取某宿舍在指定时间段内的入住记录(用于计费) async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) { - return this.repo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); + return this.ops.getRoomOccupanciesInPeriod(roomId, periodStart, periodEnd); } async remove(id: number) { - const occ = await this.repo.findOne({ where: { id } }); - if (!occ) throw new NotFoundException('入住记录不存在'); - if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿'); - if (occ.status === 'archived') throw new BadRequestException('入住记录已归档'); - await this.repo.update(id, { status: 'archived' }); - return { message: '已归档' }; + return this.ops.remove(id); } async batchRemove(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录'); - const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] }); - const skipped: string[] = []; - const deletableIds: number[] = []; - for (const occ of records) { - if (!occ.checkOutDate) { - skipped.push(occ.student?.name || `记录${occ.id}`); - } else { - deletableIds.push(occ.id); - } - } - let archived = 0; - if (deletableIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: deletableIds }) - .execute(); - archived = result.affected || 0; - } - const message = - skipped.length > 0 - ? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿` - : `批量归档成功,共 ${archived} 条`; - return { message, archived, skipped: skipped.length }; + return this.ops.batchRemove(ids); } async batchRestore(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('入住记录 ID 无效'); - } - const records = await this.repo.find({ where: { id: In(uniqueIds) } }); - if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); - if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) { - throw new BadRequestException('选中记录包含未退宿的异常归档记录'); - } + return this.ops.batchRestore(ids); + } - const targetIds = records.filter((record) => record.status === 'archived').map((record) => record.id); - const skipped = records.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped }; + async purge(id: number) { + return this.ops.purge(id); + } + + async batchPurge(ids: number[]) { + return this.ops.batchPurge(ids); } async batchCheckOut(dto: { @@ -383,71 +227,9 @@ export class OccupanciesService { billingEndDate?: string; checkOutReason?: string; }) { - if (!dto.ids || dto.ids.length === 0) { - throw new BadRequestException('请选择要退宿的记录'); - } - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - await runner.startTransaction(); - let success = 0; - const errors: string[] = []; - try { - for (const id of dto.ids) { - const occ = await runner.manager.findOne(Occupancy, { - where: { id }, - relations: ['student'], - }); - if (!occ) { - errors.push(`记录${id}不存在`); - continue; - } - if (occ.checkOutDate) { - errors.push(`${occ.student?.name || id}已退宿`); - continue; - } - try { - this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder( - occ.billingStartDate || occ.checkInDate, - dto.billingEndDate || dto.checkOutDate, - '计费截止日不能早于计费起始日', - ); - } catch (error) { - errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`); - continue; - } - occ.checkOutDate = dto.checkOutDate; - occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; - occ.checkOutReason = dto.checkOutReason || ''; - await runner.manager.save(occ); - // 更新房间状态 - await runner.manager.update(Room, occ.roomId, { status: 'available' }); - // 释放床位/柜子 - if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); - if (occ.lockerId) - await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); - success++; - } - await runner.commitTransaction(); - } catch (err) { - await runner.rollbackTransaction(); - throw err; - } finally { - await runner.release(); - } - return { - success, - failed: errors.length, - message: `已成功退宿 ${success} 人${errors.length > 0 ? `,${errors.length} 条失败` : ''}`, - errors: errors.length > 0 ? errors : undefined, - }; + return this.ops.batchCheckOut(dto); } - /** - * 一键导入入住名单 - * 每行数据:姓名、电话、学号、房间号、楼栋、入住日期 - * 自动创建不存在的学生和宿舍,并登记入住 - */ async batchImportCheckIn( rows: { name: string; @@ -471,276 +253,6 @@ export class OccupanciesService { }[], options?: { autoDeposit?: boolean; depositAmount?: number }, ) { - let imported = 0; - let skipped = 0; - let depositsCreated = 0; - const errors: string[] = []; - const importDepositAmount = options?.autoDeposit - ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') - : undefined; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; // Excel第2行开始(第1行是表头) - - if (!row.name?.trim() || !row.roomNumber?.trim()) { - skipped++; - continue; - } - - try { - const result = await this.dataSource.transaction(async (manager) => { - const occupancyRepo = manager.getRepository(Occupancy); - const roomRepo = manager.getRepository(Room); - const studentRepo = manager.getRepository(Student); - const depositRepo = manager.getRepository(Deposit); - const bedRepo = manager.getRepository(Bed); - const lockerRepo = manager.getRepository(Locker); - const organizationRepo = manager.getRepository(Organization); - let rowDepositsCreated = 0; - - // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 - const phone = row.phone?.trim(); - if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); - - let student = await studentRepo.findOne({ where: { phone } }); - if (!student) { - const hostOrganization = await organizationRepo.findOne({ - where: { isHost: true, status: 'active' }, - }); - if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); - - student = await studentRepo.save( - studentRepo.create({ - name: row.name.trim(), - phone, - studentNo: row.studentNo?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender?.trim() || undefined, - ethnicity: row.ethnicity?.trim() || undefined, - emergencyContact: row.emergencyContact?.trim() || undefined, - emergencyPhone: row.emergencyPhone?.trim() || undefined, - organizationId: hostOrganization.id, - supervisor: row.supervisor?.trim() || undefined, - }), - ); - } else { - // 更新已有学生的缺失信息 - const updates: any = {}; - if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); - if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim(); - if (!student.emergencyContact && row.emergencyContact?.trim()) - updates.emergencyContact = row.emergencyContact.trim(); - if (!student.emergencyPhone && row.emergencyPhone?.trim()) - updates.emergencyPhone = row.emergencyPhone.trim(); - if (!student.supervisor && row.supervisor?.trim()) - updates.supervisor = row.supervisor.trim(); - if (Object.keys(updates).length > 0) { - await studentRepo.update(student.id, updates); - Object.assign(student, updates); - } - } - - // 2. 查找或创建宿舍(使用智能解析) - let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (!room) { - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - room = await roomRepo.save( - roomRepo.create({ - roomNumber: row.roomNumber.trim(), - building: row.building?.trim() || parsed.building || undefined, - floor: parsed.floor || undefined, - capacity: parsed.capacity || 4, - roomType: parsed.roomType || undefined, - }), - ); - } - - const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; - const checkOutDate = row.checkOutDate?.trim(); - const billingStartDate = row.billingStartDate?.trim() || checkInDate; - const isHistoricalRecord = Boolean(checkOutDate); - this.assertDateOnly(checkInDate, '入住日期'); - this.assertDateOnly(billingStartDate, '计费起始日'); - this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); - if (checkOutDate) { - this.assertDateOnly(checkOutDate, '退宿日期'); - this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); - } - - // 3. 检查是否已有活跃入住(历史记录不影响当前入住) - const existing = await occupancyRepo.findOne({ - where: { studentId: student.id, checkOutDate: IsNull() }, - relations: ['room'], - }); - if (existing && !isHistoricalRecord) { - throw new ImportRowSkipped( - `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, - ); - } - - // 4. 检查宿舍容量 - const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); - if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { - throw new ImportRowSkipped( - `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, - ); - } - - // 5. 匹配或创建床位、柜子,并校验是否可用 - let bed: Bed | null = null; - if (row.bedNumber?.trim()) { - const bedNumber = row.bedNumber.trim(); - bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); - if (!bed) { - const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); - if (existingBedCount >= (room.capacity ?? 0)) { - throw new BadRequestException( - `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, - ); - } - bed = await bedRepo.save( - bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && bed.status !== 'available') { - throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); - } - } - - let locker: Locker | null = null; - if (row.lockerNumber?.trim()) { - const lockerNumber = row.lockerNumber.trim(); - locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); - if (!locker) { - locker = await lockerRepo.save( - lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && locker.status !== 'available') { - throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); - } - } - - // 6. 创建入住记录 - const occData: any = { - studentId: student.id, - roomId: room.id, - checkInDate, - billingStartDate, - stayType: row.stayType || undefined, - responsibleOrganizationId: student.organizationId, - notes: row.notes || undefined, - bedId: bed?.id, - lockerId: locker?.id, - }; - // 如果有退宿日期,直接记录 - if (checkOutDate) { - occData.checkOutDate = checkOutDate; - occData.billingEndDate = checkOutDate; - } - await occupancyRepo.save(occupancyRepo.create(occData)); - - // 7. 更新床位、柜子和宿舍状态 - if (!isHistoricalRecord) { - if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); - if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); - if (count + 1 >= (room.capacity ?? 0)) { - await roomRepo.update(room.id, { status: 'full' }); - } - } - - // 9. 自动收取押金(仅对新入住且非历史记录的学生) - if (options?.autoDeposit && !isHistoricalRecord) { - const existingDeposit = await depositRepo.findOne({ - where: { studentId: student.id }, - }); - const depositAmount = importDepositAmount!; - const hasPaidDeposit = - existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; - if (hasPaidDeposit) { - // 导入重试或重复导入时,已有已缴押金不重复收取。 - } else if (existingDeposit) { - existingDeposit.amount = depositAmount; - existingDeposit.status = 'paid'; - existingDeposit.paidDate = checkInDate; - existingDeposit.refundDate = null as unknown as string; - existingDeposit.refundAmount = null as unknown as number; - existingDeposit.refundedBy = null; - existingDeposit.refundedAt = null; - existingDeposit.notes = '入住导入自动收取'; - await depositRepo.save(existingDeposit); - rowDepositsCreated++; - } else { - await depositRepo.save( - depositRepo.create({ - studentId: student.id, - amount: depositAmount, - paidDate: checkInDate, - status: 'paid', - notes: '入住导入自动收取', - }), - ); - rowDepositsCreated++; - } - } - - return { depositsCreated: rowDepositsCreated }; - }); - - imported++; - depositsCreated += result.depositsCreated; - } catch (e: any) { - errors.push( - e instanceof ImportRowSkipped - ? e.message - : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, - ); - skipped++; - } - } - - const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; - return { - message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, - imported, - skipped, - depositsCreated, - errors: errors.length > 0 ? errors : undefined, - }; - } - - private normalizePositiveMoney(value: number, label: string): number { - const amount = Number(value); - if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { - throw new BadRequestException(`${label}最多保留两位小数`); - } - if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); - return Number(amount.toFixed(2)); - } - - private assertDateOnly(value: string, label: string): void { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); - } - const [year, month, day] = value.split('-').map(Number); - const date = new Date(Date.UTC(year, month - 1, day)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() + 1 !== month || - date.getUTCDate() !== day - ) { - throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); - } - } - - private assertDateOrder(start: string, end: string | undefined, message: string): void { - this.assertDateOnly(start, '起始日期'); - if (!end) return; - this.assertDateOnly(end, '结束日期'); - if (end < start) throw new BadRequestException(message); + return this.ops.batchImportCheckIn(rows, options); } } diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index 4fbe5ff..6437e81 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -81,7 +81,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string { return `${year}-${month}-${day}`; } const text = cellText(cell); - const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/); + const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/); if (!matched) return text; return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`; } diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts new file mode 100644 index 0000000..f1f9ec8 --- /dev/null +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -0,0 +1,311 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { DataSource, IsNull } from 'typeorm'; +import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; +import { RoomsService } from '../rooms/rooms.service'; + +class ImportRowSkipped extends Error {} + +@Injectable() +export class OccupancyImportService { + constructor(private dataSource: DataSource) {} + + async batchImportCheckIn( + rows: { + name: string; + phone?: string; + studentNo?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + supervisor?: string; + roomNumber: string; + building?: string; + checkInDate: string; + billingStartDate?: string; + checkOutDate?: string; + bedNumber?: string; + lockerNumber?: string; + stayType?: string; + notes?: string; + }[], + options?: { autoDeposit?: boolean; depositAmount?: number }, + ) { + let imported = 0; + let skipped = 0; + let depositsCreated = 0; + const errors: string[] = []; + const importDepositAmount = options?.autoDeposit + ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') + : undefined; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; // Excel第2行开始(第1行是表头) + + if (!row.name?.trim() || !row.roomNumber?.trim()) { + skipped++; + continue; + } + + try { + const result = await this.dataSource.transaction(async (manager) => { + const occupancyRepo = manager.getRepository(Occupancy); + const roomRepo = manager.getRepository(Room); + const studentRepo = manager.getRepository(Student); + const depositRepo = manager.getRepository(Deposit); + const bedRepo = manager.getRepository(Bed); + const lockerRepo = manager.getRepository(Locker); + const organizationRepo = manager.getRepository(Organization); + let rowDepositsCreated = 0; + + // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 + const phone = row.phone?.trim(); + if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); + + let student = await studentRepo.findOne({ where: { phone } }); + if (!student) { + const hostOrganization = await organizationRepo.findOne({ + where: { isHost: true, status: 'active' }, + }); + if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); + + student = await studentRepo.save( + studentRepo.create({ + name: row.name.trim(), + phone, + studentNo: row.studentNo?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender?.trim() || undefined, + ethnicity: row.ethnicity?.trim() || undefined, + emergencyContact: row.emergencyContact?.trim() || undefined, + emergencyPhone: row.emergencyPhone?.trim() || undefined, + organizationId: hostOrganization.id, + supervisor: row.supervisor?.trim() || undefined, + }), + ); + } else { + // 更新已有学生的缺失信息 + const updates: any = {}; + if (!student.studentNo && row.studentNo?.trim()) + updates.studentNo = row.studentNo.trim(); + if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); + if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); + if (!student.ethnicity && row.ethnicity?.trim()) + updates.ethnicity = row.ethnicity.trim(); + if (!student.emergencyContact && row.emergencyContact?.trim()) + updates.emergencyContact = row.emergencyContact.trim(); + if (!student.emergencyPhone && row.emergencyPhone?.trim()) + updates.emergencyPhone = row.emergencyPhone.trim(); + if (!student.supervisor && row.supervisor?.trim()) + updates.supervisor = row.supervisor.trim(); + if (Object.keys(updates).length > 0) { + await studentRepo.update(student.id, updates); + Object.assign(student, updates); + } + } + + // 2. 查找或创建宿舍(使用智能解析) + let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (!room) { + const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); + room = await roomRepo.save( + roomRepo.create({ + roomNumber: row.roomNumber.trim(), + building: row.building?.trim() || parsed.building || undefined, + floor: parsed.floor || undefined, + capacity: parsed.capacity || 4, + roomType: parsed.roomType || undefined, + }), + ); + } + + const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; + const checkOutDate = row.checkOutDate?.trim(); + const billingStartDate = row.billingStartDate?.trim() || checkInDate; + const isHistoricalRecord = Boolean(checkOutDate); + this.assertDateOnly(checkInDate, '入住日期'); + this.assertDateOnly(billingStartDate, '计费起始日'); + this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); + if (checkOutDate) { + this.assertDateOnly(checkOutDate, '退宿日期'); + this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); + } + + // 3. 检查是否已有活跃入住(历史记录不影响当前入住) + const existing = await occupancyRepo.findOne({ + where: { studentId: student.id, checkOutDate: IsNull() }, + relations: ['room'], + }); + if (existing && !isHistoricalRecord) { + throw new ImportRowSkipped( + `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, + ); + } + + // 4. 检查宿舍容量 + const count = await occupancyRepo.count({ + where: { roomId: room.id, checkOutDate: IsNull() }, + }); + if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { + throw new ImportRowSkipped( + `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, + ); + } + + // 5. 匹配或创建床位、柜子,并校验是否可用 + let bed: Bed | null = null; + if (row.bedNumber?.trim()) { + const bedNumber = row.bedNumber.trim(); + bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); + if (!bed) { + const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); + if (existingBedCount >= (room.capacity ?? 0)) { + throw new BadRequestException( + `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, + ); + } + bed = await bedRepo.save( + bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && bed.status !== 'available') { + throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); + } + } + + let locker: Locker | null = null; + if (row.lockerNumber?.trim()) { + const lockerNumber = row.lockerNumber.trim(); + locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); + if (!locker) { + locker = await lockerRepo.save( + lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && locker.status !== 'available') { + throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); + } + } + + // 6. 创建入住记录 + const occData: any = { + studentId: student.id, + roomId: room.id, + checkInDate, + billingStartDate, + stayType: row.stayType || undefined, + responsibleOrganizationId: student.organizationId, + notes: row.notes || undefined, + bedId: bed?.id, + lockerId: locker?.id, + }; + // 如果有退宿日期,直接记录 + if (checkOutDate) { + occData.checkOutDate = checkOutDate; + occData.billingEndDate = checkOutDate; + } + await occupancyRepo.save(occupancyRepo.create(occData)); + + // 7. 更新床位、柜子和宿舍状态 + if (!isHistoricalRecord) { + if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); + if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); + if (count + 1 >= (room.capacity ?? 0)) { + await roomRepo.update(room.id, { status: 'full' }); + } + } + + // 9. 自动收取押金(仅对新入住且非历史记录的学生) + if (options?.autoDeposit && !isHistoricalRecord) { + const existingDeposit = await depositRepo.findOne({ + where: { studentId: student.id }, + }); + const depositAmount = importDepositAmount!; + const hasPaidDeposit = + existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; + if (hasPaidDeposit) { + // 导入重试或重复导入时,已有已缴押金不重复收取。 + } else if (existingDeposit) { + existingDeposit.amount = depositAmount; + existingDeposit.status = 'paid'; + existingDeposit.paidDate = checkInDate; + (existingDeposit as { refundDate: string | null }).refundDate = null; + (existingDeposit as { refundAmount: number | null }).refundAmount = null; + (existingDeposit as { refundedBy: number | null }).refundedBy = null; + (existingDeposit as { refundedAt: Date | null }).refundedAt = null; + existingDeposit.notes = '入住导入自动收取'; + await depositRepo.save(existingDeposit); + rowDepositsCreated++; + } else { + await depositRepo.save( + depositRepo.create({ + studentId: student.id, + amount: depositAmount, + paidDate: checkInDate, + status: 'paid', + notes: '入住导入自动收取', + }), + ); + rowDepositsCreated++; + } + } + + return { depositsCreated: rowDepositsCreated }; + }); + + imported++; + depositsCreated += result.depositsCreated; + } catch (e: any) { + errors.push( + e instanceof ImportRowSkipped + ? e.message + : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, + ); + skipped++; + } + } + + const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; + return { + message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, + imported, + skipped, + depositsCreated, + errors: errors.length > 0 ? errors : undefined, + }; + } + + private normalizePositiveMoney(value: number, label: string): number { + const amount = value; + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException(`${label}最多保留两位小数`); + } + if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); + return Number(amount.toFixed(2)); + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() + 1 !== month || + date.getUTCDate() !== day + ) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + this.assertDateOnly(start, '起始日期'); + if (!end) return; + this.assertDateOnly(end, '结束日期'); + if (end < start) throw new BadRequestException(message); + } +} diff --git a/apps/server/src/occupancies/occupancy-lock.ts b/apps/server/src/occupancies/occupancy-lock.ts new file mode 100644 index 0000000..3e7a16d --- /dev/null +++ b/apps/server/src/occupancies/occupancy-lock.ts @@ -0,0 +1,12 @@ +import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm'; + +export function withPessimisticWriteLock( + qb: SelectQueryBuilder, + dataSource: DataSource, +): SelectQueryBuilder { + const type = dataSource.options.type; + if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { + return qb.setLock('pessimistic_write'); + } + return qb; +} diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts new file mode 100644 index 0000000..749809c --- /dev/null +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -0,0 +1,420 @@ +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, IsNull, In } from 'typeorm'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Bed } from '../entities/bed.entity'; +import { Locker } from '../entities/locker.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; +import { CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; +import { OccupancyImportService } from './occupancy-import.service'; +import { withPessimisticWriteLock } from './occupancy-lock'; + +@Injectable() +export class OccupancyOperationsService { + constructor( + @InjectRepository(Occupancy) private repo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + @InjectRepository(Locker) private lockerRepo: Repository, + @InjectRepository(Organization) private organizationRepo: Repository, + private dataSource: DataSource, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, + @Optional() private imports?: OccupancyImportService, + ) {} + + private get imp(): OccupancyImportService { + if (!this.imports) this.imports = new OccupancyImportService(this.dataSource); + return this.imports; + } + + private normalizePositiveMoney(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException(`${label}必须为非负数字`); + } + return Math.round(value * 100) / 100; + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) { + throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`); + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + if (end && start > end) throw new BadRequestException(message); + } + + async checkOut(occupancyId: number, dto: CheckOutDto) { + return this.dataSource.transaction(async (manager) => { + const occ = await withPessimisticWriteLock( + manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + this.dataSource).getOne(); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + occ.checkOutDate = dto.checkOutDate; + occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; + occ.checkOutReason = dto.checkOutReason || ''; + await manager.save(occ); + if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); + if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); + await manager.update(Room, occ.roomId, { status: 'available' }); + return occ; + }); + } + + async transferRoom(occupancyId: number, dto: TransferRoomDto) { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + await runner.startTransaction(); + try { + const oldOcc = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + this.dataSource).getOne(); + if (!oldOcc) throw new NotFoundException('入住记录不存在'); + if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); + if (oldOcc.roomId === dto.newRoomId) + throw new BadRequestException('目标宿舍不能与当前宿舍相同'); + this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期'); + this.assertDateOrder( + oldOcc.billingStartDate || oldOcc.checkInDate, + dto.oldBillingEndDate || dto.transferDate, + '原宿舍计费截止日不能早于计费起始日', + ); + + // 退旧房 + oldOcc.checkOutDate = dto.transferDate; + oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate; + oldOcc.checkOutReason = dto.reason || '换房'; + await runner.manager.save(oldOcc); + // 释放旧床位/柜子 + if (oldOcc.bedId) { + await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' }); + } + if (oldOcc.lockerId) { + await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); + } + await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + // 检查新房容量 + const newRoom = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Room, 'room') + .where('room.id = :roomId', { roomId: dto.newRoomId }), + this.dataSource).getOne(); + if (!newRoom) throw new NotFoundException('目标宿舍不存在'); + if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { + throw new BadRequestException('目标宿舍当前不可入住'); + } + const count = await runner.manager.count(Occupancy, { + where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, + }); + if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满'); + + // 新床位校验 + if (dto.newBedId) { + const newBed = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Bed, 'bed') + .where('bed.id = :bedId AND bed.roomId = :roomId', { + bedId: dto.newBedId, + roomId: dto.newRoomId, + }), + this.dataSource).getOne(); + if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); + if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); + } + if (dto.newLockerId) { + const newLocker = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { + lockerId: dto.newLockerId, + roomId: dto.newRoomId, + }), + this.dataSource).getOne(); + if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); + if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); + } + + // 计算新房计费起始日:默认为换房日期次日 + const transferDate = new Date(dto.transferDate); + const nextDay = new Date(transferDate); + nextDay.setDate(nextDay.getDate() + 1); + const defaultBillingStart = nextDay.toISOString().split('T')[0]; + this.assertDateOrder( + dto.transferDate, + dto.newBillingStartDate || defaultBillingStart, + '新宿舍计费起始日不能早于换房日期', + ); + + // 入住新房 + const newOcc = runner.manager.create(Occupancy, { + studentId: oldOcc.studentId, + roomId: dto.newRoomId, + checkInDate: dto.transferDate, + billingStartDate: dto.newBillingStartDate || defaultBillingStart, + stayType: oldOcc.stayType, + responsibleOrganizationId: oldOcc.responsibleOrganizationId, + notes: `从${oldOcc.roomId}号房换入`, + bedId: dto.newBedId, + lockerId: dto.newLockerId, + }); + await runner.manager.save(newOcc); + + // 更新新床位/柜子状态 + if (dto.newBedId) { + await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' }); + } + if (dto.newLockerId) { + await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); + } + + if (count + 1 >= (newRoom.capacity ?? 0)) { + await runner.manager.update(Room, newRoom.id, { status: 'full' }); + } + + await runner.commitTransaction(); + return { oldOccupancy: oldOcc, newOccupancy: newOcc }; + } catch (err) { + await runner.rollbackTransaction(); + throw err; + } finally { + await runner.release(); + } + } + + // 获取某宿舍在指定时间段内的入住记录(用于计费) + async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) { + return this.repo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .where('o.roomId = :roomId', { roomId }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); + } + + async remove(id: number) { + const occ = await this.repo.findOne({ where: { id } }); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿'); + if (occ.status === 'archived') throw new BadRequestException('入住记录已归档'); + await this.repo.update(id, { status: 'archived' }); + return { message: '已归档' }; + } + + async batchRemove(ids: number[]) { + if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录'); + const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] }); + const skipped: string[] = []; + const deletableIds: number[] = []; + for (const occ of records) { + if (!occ.checkOutDate) { + skipped.push(occ.student?.name || `记录${occ.id}`); + } else { + deletableIds.push(occ.id); + } + } + let archived = 0; + if (deletableIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: deletableIds }) + .execute(); + archived = result.affected || 0; + } + const message = + skipped.length > 0 + ? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿` + : `批量归档成功,共 ${archived} 条`; + return { message, archived, skipped: skipped.length }; + } + + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('入住记录 ID 无效'); + } + const records = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); + if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) { + throw new BadRequestException('选中记录包含未退宿的异常归档记录'); + } + + const targetIds = records + .filter((record) => record.status === 'archived') + .map((record) => record.id); + const skipped = records.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped }; + } + + async purge(id: number) { + const occ = await this.repo.findOne({ where: { id } }); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.status !== 'archived') + throw new BadRequestException('仅已归档入住记录可以永久删除,请先归档'); + const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: id } }); + if (detailCount > 0) throw new BadRequestException('该入住记录已被查寝记录引用,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除入住记录(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的入住记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('入住记录 ID 无效'); + } + const records = await this.repo.find({ where: { id: In(uniqueIds) }, relations: ['student'] }); + if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const occ of records) { + if (occ.status !== 'archived') { + skipped.push(`${occ.student?.name || `记录${occ.id}`}(未归档)`); + continue; + } + const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: occ.id } }); + if (detailCount > 0) { + skipped.push(`${occ.student?.name || `记录${occ.id}`}(存在关联数据)`); + continue; + } + await this.repo.delete(occ.id); + deleted.push(occ.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条入住记录(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async batchCheckOut(dto: { + ids: number[]; + checkOutDate: string; + billingEndDate?: string; + checkOutReason?: string; + }) { + if (!dto.ids || dto.ids.length === 0) { + throw new BadRequestException('请选择要退宿的记录'); + } + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + await runner.startTransaction(); + let success = 0; + const errors: string[] = []; + try { + for (const id of dto.ids) { + const occ = await runner.manager.findOne(Occupancy, { + where: { id }, + relations: ['student'], + }); + if (!occ) { + errors.push(`记录${id}不存在`); + continue; + } + if (occ.checkOutDate) { + errors.push(`${occ.student?.name || id}已退宿`); + continue; + } + try { + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + } catch (error) { + errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`); + continue; + } + occ.checkOutDate = dto.checkOutDate; + occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; + occ.checkOutReason = dto.checkOutReason || ''; + await runner.manager.save(occ); + // 更新房间状态 + await runner.manager.update(Room, occ.roomId, { status: 'available' }); + // 释放床位/柜子 + if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); + if (occ.lockerId) + await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); + success++; + } + await runner.commitTransaction(); + } catch (err) { + await runner.rollbackTransaction(); + throw err; + } finally { + await runner.release(); + } + return { + success, + failed: errors.length, + message: `已成功退宿 ${success} 人${errors.length > 0 ? `,${errors.length} 条失败` : ''}`, + errors: errors.length > 0 ? errors : undefined, + }; + } + + /** + * 一键导入入住名单 + * 每行数据:姓名、电话、学号、房间号、楼栋、入住日期 + * 自动创建不存在的学生和宿舍,并登记入住 + */ + async batchImportCheckIn( + rows: { + name: string; + phone?: string; + studentNo?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + supervisor?: string; + roomNumber: string; + building?: string; + checkInDate: string; + billingStartDate?: string; + checkOutDate?: string; + bedNumber?: string; + lockerNumber?: string; + stayType?: string; + notes?: string; + }[], + options?: { autoDeposit?: boolean; depositAmount?: number }, + ) { + return this.imp.batchImportCheckIn(rows, options); + } +} diff --git a/apps/server/src/organizations/organizations.controller.spec.ts b/apps/server/src/organizations/organizations.controller.spec.ts index feb7131..9b02a3e 100644 --- a/apps/server/src/organizations/organizations.controller.spec.ts +++ b/apps/server/src/organizations/organizations.controller.spec.ts @@ -21,3 +21,25 @@ describe('OrganizationsController permissions', () => { ]); }); }); + +describe('OrganizationsController', () => { + it('requires organization:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.purge)).toEqual([ + 'organization:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除机构(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new OrganizationsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '机构管理', action: '永久删除机构', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index 6650420..9548256 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -101,4 +101,23 @@ export class OrganizationsController { }); return result; } + + @Delete(':id/permanent') + @RequirePermission('organization:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.purge(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '机构管理', + action: '永久删除机构', + targetId: +id, + targetType: 'organization', + detail: '物理删除,不可恢复', + ipAddress, + userAgent, + }); + return result; + } } diff --git a/apps/server/src/organizations/organizations.module.ts b/apps/server/src/organizations/organizations.module.ts index 763d34f..81172a1 100644 --- a/apps/server/src/organizations/organizations.module.ts +++ b/apps/server/src/organizations/organizations.module.ts @@ -1,12 +1,18 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Organization } from '../entities/organization.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; import { OrganizationsService } from './organizations.service'; import { OrganizationsController } from './organizations.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule], + imports: [ + TypeOrmModule.forFeature([Organization, Student, Occupancy, ClassroomRental]), + OperationLogsModule, + ], controllers: [OrganizationsController], providers: [OrganizationsService], exports: [OrganizationsService], diff --git a/apps/server/src/organizations/organizations.purge.spec.ts b/apps/server/src/organizations/organizations.purge.spec.ts new file mode 100644 index 0000000..193a521 --- /dev/null +++ b/apps/server/src/organizations/organizations.purge.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; +import { OrganizationsService } from './organizations.service'; + +describe('OrganizationsService.purge', () => { + const createService = (overrides?: { + organization?: Record; + studentCount?: number; + occupancyCount?: number; + lessorCount?: number; + lesseeCount?: number; + }) => { + const organization = { + id: 1, + name: '合作机构', + status: 'archived', + isHost: false, + ...overrides?.organization, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(organization), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const studentRepo = { count: jest.fn().mockResolvedValue(overrides?.studentCount ?? 0) }; + const occupancyRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) }; + const rentalRepo = { + count: jest.fn().mockResolvedValue(overrides?.lessorCount ?? 0), + }; + rentalRepo.count.mockResolvedValueOnce(overrides?.lessorCount ?? 0); + rentalRepo.count.mockResolvedValueOnce(overrides?.lesseeCount ?? 0); + const service = new OrganizationsService( + repo as never, + studentRepo as never, + occupancyRepo as never, + rentalRepo as never, + ); + return { service, repo, studentRepo, occupancyRepo, rentalRepo }; + }; + + it('rejects organizations that are not archived or are the host', async () => { + const notArchived = createService({ organization: { status: 'active' } }); + await expect(notArchived.service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档机构可以永久删除,请先归档'), + ); + + const host = createService({ organization: { isHost: true } }); + await expect(host.service.purge(1)).rejects.toThrow( + new BadRequestException('本机构不能永久删除'), + ); + expect(notArchived.repo.delete).not.toHaveBeenCalled(); + expect(host.repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects organizations with student, occupancy, or rental references', async () => { + const withStudents = createService({ studentCount: 1 }); + await expect(withStudents.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(学生归属),无法永久删除'), + ); + + const withOccupancy = createService({ occupancyCount: 1 }); + await expect(withOccupancy.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(入住责任机构),无法永久删除'), + ); + + const withLessee = createService({ lesseeCount: 1 }); + await expect(withLessee.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(承租租赁订单),无法永久删除'), + ); + expect(withLessee.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived organization with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除机构(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts index f4128cb..ee38778 100644 --- a/apps/server/src/organizations/organizations.service.ts +++ b/apps/server/src/organizations/organizations.service.ts @@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { Organization } from '../entities/organization.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto'; const COLOR_PALETTE = [ @@ -20,7 +23,12 @@ const COLOR_PALETTE = [ @Injectable() export class OrganizationsService { - constructor(@InjectRepository(Organization) private repo: Repository) {} + constructor( + @InjectRepository(Organization) private repo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(Occupancy) private occupancyRepo: Repository, + @InjectRepository(ClassroomRental) private rentalRepo: Repository, + ) {} async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) { const where: Record = {}; @@ -86,4 +94,28 @@ export class OrganizationsService { await this.repo.update(id, { status: 'archived' }); return { message: '已归档' }; } + + async purge(id: number) { + const organization = await this.findOne(id); + if (organization.status !== 'archived') { + throw new BadRequestException('仅已归档机构可以永久删除,请先归档'); + } + if (organization.isHost) throw new BadRequestException('本机构不能永久删除'); + const [studentCount, occupancyCount, lessorCount, lesseeCount] = await Promise.all([ + this.studentRepo.count({ where: { organizationId: id } }), + this.occupancyRepo.count({ where: { responsibleOrganizationId: id } }), + this.rentalRepo.count({ where: { lessorOrganizationId: id } }), + this.rentalRepo.count({ where: { lesseeOrganizationId: id } }), + ]); + const references: string[] = []; + if (studentCount > 0) references.push('学生归属'); + if (occupancyCount > 0) references.push('入住责任机构'); + if (lessorCount > 0) references.push('出租租赁订单'); + if (lesseeCount > 0) references.push('承租租赁订单'); + if (references.length > 0) { + throw new BadRequestException(`该机构存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.repo.delete(id); + return { message: '已永久删除机构(不可恢复)' }; + } } diff --git a/apps/server/src/rooms/room-bed-locker.service.ts b/apps/server/src/rooms/room-bed-locker.service.ts new file mode 100644 index 0000000..cfe8d00 --- /dev/null +++ b/apps/server/src/rooms/room-bed-locker.service.ts @@ -0,0 +1,190 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not } from 'typeorm'; +import { Room } from '../entities/room.entity'; +import { Bed } from '../entities/bed.entity'; +import { Locker } from '../entities/locker.entity'; +import type { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; +import type { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; + +@Injectable() +export class RoomBedLockerService { + constructor( + @InjectRepository(Room) private repo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + @InjectRepository(Locker) private lockerRepo: Repository, + ) {} + + async getRoomBeds(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.bedRepo.find({ + where: { roomId, status: Not('archived') }, + order: { bedNumber: 'ASC' }, + }); + } + + async getRoomAvailableBeds(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.bedRepo.find({ + where: { roomId, status: 'available' }, + order: { bedNumber: 'ASC' }, + }); + } + + async createBed(roomId: number, dto: CreateBedDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); + await this.assertCanAddBeds(room, 1); + const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); + if (existing) throw new BadRequestException('该床位编号已存在'); + const bed = this.bedRepo.create({ ...dto, roomId }); + return this.bedRepo.save(bed); + } + + async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { + const bed = await this.bedRepo.findOne({ where: { id, roomId } }); + if (!bed) throw new NotFoundException('床位不存在'); + // 不允许将 occupied 的床位改为 maintenance + if (dto.status === 'maintenance' && bed.status === 'occupied') { + throw new BadRequestException('该床位有人入住,请先退宿'); + } + // 编号唯一性检查 + if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { + const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); + if (dup) throw new BadRequestException('该床位编号已存在'); + } + Object.assign(bed, dto); + return this.bedRepo.save(bed); + } + + async deleteBed(roomId: number, id: number): Promise { + const bed = await this.bedRepo.findOne({ where: { id, roomId } }); + if (!bed) throw new NotFoundException('床位不存在'); + if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档'); + if (bed.status === 'archived') throw new BadRequestException('该床位已归档'); + await this.bedRepo.update(id, { status: 'archived' }); + } + + async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); + const existing = await this.bedRepo.find({ + where: { roomId, status: Not('archived') }, + order: { bedNumber: 'ASC' }, + }); + this.assertCanAddBedsFromCount(room, existing.length, dto.count); + const numbers = existing.map((b) => { + const match = b.bedNumber.match(/^\d+/); + return match ? parseInt(match[0]) : 0; + }); + const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + const beds: Bed[] = []; + for (let i = 0; i < dto.count; i++) { + beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); + } + return this.bedRepo.save(beds); + } + + + getNextBedNumber(beds: Pick[]): number { + const numbers = beds.map((bed) => { + const match = bed.bedNumber.match(/^\d+/); + return match ? parseInt(match[0], 10) : 0; + }); + return numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + } + + private async assertCanAddBeds(room: Room, count: number): Promise { + const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); + this.assertCanAddBedsFromCount(room, existingCount, count); + } + + private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { + const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); + if (count > remaining) { + throw new BadRequestException( + `床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`, + ); + } + } + + // ── 柜子管理 ── + + async getRoomLockers(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.lockerRepo.find({ + where: { roomId, status: Not('archived') }, + order: { lockerNumber: 'ASC' }, + }); + } + + async getRoomAvailableLockers(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.lockerRepo.find({ + where: { roomId, status: 'available' }, + order: { lockerNumber: 'ASC' }, + }); + } + + async createLocker(roomId: number, dto: CreateLockerDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); + const existing = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); + if (existing) throw new BadRequestException('该柜子编号已存在'); + const locker = this.lockerRepo.create({ ...dto, roomId }); + return this.lockerRepo.save(locker); + } + + async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { + const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); + if (!locker) throw new NotFoundException('柜子不存在'); + if (dto.status === 'maintenance' && locker.status === 'occupied') { + throw new BadRequestException('该柜子有人占用,请先释放'); + } + if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { + const dup = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); + if (dup) throw new BadRequestException('该柜子编号已存在'); + } + Object.assign(locker, dto); + return this.lockerRepo.save(locker); + } + + async deleteLocker(roomId: number, id: number): Promise { + const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); + if (!locker) throw new NotFoundException('柜子不存在'); + if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档'); + if (locker.status === 'archived') throw new BadRequestException('该柜子已归档'); + await this.lockerRepo.update(id, { status: 'archived' }); + } + + async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); + const existing = await this.lockerRepo.find({ + where: { roomId, status: Not('archived') }, + order: { lockerNumber: 'ASC' }, + }); + const numbers = existing.map((b) => { + const match = b.lockerNumber.match(/^\d+/); + return match ? parseInt(match[0]) : 0; + }); + const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + const lockers: Locker[] = []; + for (let i = 0; i < dto.count; i++) { + lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); + } + return this.lockerRepo.save(lockers); + } +} diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts index 5283499..7bf4d5f 100644 --- a/apps/server/src/rooms/room-inspections.service.ts +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm'; -import { Bed } from '../entities/bed.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { RoomInspection } from '../entities/room-inspection.entity'; @@ -35,7 +34,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { await this.settlePreviousDay().catch((error) => { - this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error)); + this.logger.error( + '补记昨日宿舍查寝失败', + error instanceof Error ? error.stack : String(error), + ); }); } @@ -67,7 +69,9 @@ export class RoomInspectionsService implements OnApplicationBootstrap { const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id)); const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id)); if (invalidIds.length > 0) { - throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`); + throw new BadRequestException( + `存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`, + ); } const inspectionRepo = manager.getRepository(RoomInspection); @@ -128,7 +132,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap { async settleDate(inspectionDate: string): Promise { const existing = await this.inspectionRepo.find({ where: { inspectionDate } }); const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId)); - const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate); + const occupancies = await this.findAllOccupanciesForDate( + this.dataSource.manager, + inspectionDate, + ); const byRoom = new Map(); for (const occupancy of occupancies) { if (existingRoomIds.has(occupancy.roomId)) continue; diff --git a/apps/server/src/rooms/room-number.ts b/apps/server/src/rooms/room-number.ts new file mode 100644 index 0000000..d071dce --- /dev/null +++ b/apps/server/src/rooms/room-number.ts @@ -0,0 +1,38 @@ +/** 智能解析房间号,自动推导楼栋、楼层、宿舍类型 */ +export function parseRoomNumber(roomNumber: string): { + building?: string; + floor?: number; + roomType?: string; + capacity?: number; +} { + const cleaned = roomNumber.replace(/[((].*?[))]/g, '').trim(); + // 家庭房: X-Y-ZZZ 格式 + const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/); + if (familyMatch) { + const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; + const roomPart = familyMatch[3]; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; + return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; + } + // 标准: X-YZZ 格式 + const stdMatch = cleaned.match(/^(\d+)-(\d+)$/); + if (stdMatch) { + const bldgNum = stdMatch[1]; + const roomPart = stdMatch[2]; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; + const building = `${bldgNum}号楼`; + let roomType = '四人间'; + let capacity = 4; + if (bldgNum === '2') { + roomType = '单人间'; + capacity = 1; + } else if (bldgNum === '8') { + roomType = '爆改房'; + capacity = 2; + } + return { building, floor, roomType, capacity }; + } + return { capacity: 4, roomType: '四人间' }; +} diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts new file mode 100644 index 0000000..4e00862 --- /dev/null +++ b/apps/server/src/rooms/room-query.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not, In } from 'typeorm'; +import { Room } from '../entities/room.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Bed } from '../entities/bed.entity'; +import { RoomInspectionsService } from './room-inspections.service'; +import { occupancyWhereOnDate } from './room-occupancy-date'; +import { parseRoomNumber } from './room-number'; + +@Injectable() +export class RoomQueryService { + constructor( + @InjectRepository(Room) private repo: Repository, + @InjectRepository(Occupancy) private occRepo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + private readonly inspectionsService: RoomInspectionsService, + ) {} + + + + async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) { + const qb = this.repo.createQueryBuilder('room'); + if (query.building) qb.andWhere('room.building = :building', { building: query.building }); + if (query.keyword) { + qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); + } + if (query.status) qb.andWhere('room.status = :status', { status: query.status }); + const rows = await qb + .select([ + 'room.id', + 'room.roomNumber', + 'room.building', + 'room.floor', + 'room.capacity', + 'room.roomType', + 'room.status', + ]) + .orderBy('room.roomNumber', 'ASC') + .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) + .getRawMany(); + return rows.map((row) => ({ + id: Number(row.room_id), + roomNumber: String(row.room_room_number), + building: row.room_building == null ? null : String(row.room_building), + floor: row.room_floor == null ? null : Number(row.room_floor), + capacity: Number(row.room_capacity), + roomType: row.room_room_type == null ? null : String(row.room_room_type), + status: String(row.room_status), + })); + } + + async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { + const targetDate = query.date || this.getChinaDate(new Date()); + const qb = this.occRepo + .createQueryBuilder('o') + .innerJoin('o.room', 'room') + .where('o.checkInDate <= :date', { date: targetDate }) + .andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :date)', { date: targetDate }); + if (query.building) qb.andWhere('room.building = :building', { building: query.building }); + const rows = await qb + .select('room.id', 'roomId') + .addSelect('room.roomNumber', 'roomNumber') + .addSelect('COUNT(o.id)', 'occupied') + .addSelect('room.capacity', 'capacity') + .groupBy('room.id') + .orderBy('room.roomNumber', 'ASC') + .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) + .getRawMany(); + return rows.map((row) => ({ + roomId: Number(row.roomId), + roomNumber: String(row.roomNumber), + occupied: Number(row.occupied), + capacity: Number(row.capacity), + rate: Number(row.capacity) > 0 ? Number(((Number(row.occupied) / Number(row.capacity)) * 100).toFixed(1)) : 0, + })); + } + + async getRoomVisual(asOf?: string) { + // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 + const isHistorical = !!asOf; + const targetDate = asOf || this.getChinaDate(new Date()); + + // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 + const rooms = await this.repo.find({ + where: isHistorical ? {} : { status: Not('archived') }, + order: { building: 'ASC', roomNumber: 'ASC' }, + }); + + const occupancies = await this.occRepo.find({ + where: occupancyWhereOnDate(targetDate), + relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'], + order: { checkInDate: 'ASC' }, + }); + + // 按roomId分组入住记录 + const occMap = new Map(); + // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 + const refTime = new Date(targetDate).getTime(); + for (const occ of occupancies) { + if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); + const checkIn = new Date(occ.checkInDate); + const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); + occMap.get(occ.roomId)!.push({ + studentId: occ.studentId, + occupancyId: occ.id, + studentName: occ.student?.name || '未知', + bedId: occ.bedId ?? null, + bedNumber: occ.bed?.bedNumber || null, + checkInDate: occ.checkInDate, + billingStartDate: occ.billingStartDate, + days, + organization: occ.student?.organization?.name || null, + supervisor: occ.student?.supervisor || null, + organizationId: occ.responsibleOrganizationId || null, + organizationName: occ.responsibleOrganization?.name || null, + organizationColor: occ.responsibleOrganization?.color || null, + }); + } + + // 获取各楼栋列表 + const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))]; + + // 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。 + const visibleRooms = isHistorical + ? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0) + : rooms; + + // 批量获取床位统计 + const allBeds = await this.bedRepo.find({ + where: { roomId: In(visibleRooms.map((r) => r.id)) }, + }); + const bedMap = new Map(); + for (const bed of allBeds) { + if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 }); + const entry = bedMap.get(bed.roomId)!; + entry.total++; + if (bed.status === 'occupied') entry.occupied++; + } + + const inspectionMap = await this.inspectionsService.getByRoomsAndDate( + visibleRooms.map((room) => room.id), + targetDate, + ); + + return { + buildings, + rooms: visibleRooms.map((room) => { + const occ = occMap.get(room.id) || []; + const inspection = inspectionMap.get(room.id); + const inspectionByOccupancyId = new Map( + (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), + ); + const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; + let orgLabel: string | null = null; + if (orgs.length > 0 && occ.length > 0) { + const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); + orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; + } + const organizationColors = [ + ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), + ]; + const organizationColor: string | null = + organizationColors.length === 1 ? organizationColors[0] : null; + const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; + return { + id: room.id, + roomNumber: room.roomNumber, + building: room.building, + floor: room.floor, + capacity: room.capacity, + status: room.status, + currentCount: occ.length, + totalBeds: bedMap.get(room.id)?.total ?? 0, + occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, + occupants: occ.map((occupant) => ({ + ...occupant, + inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null, + })), + inspection: inspection + ? { + submitted: true, + inspectorId: inspection.inspectorId, + inspectorName: inspection.inspectorName, + source: inspection.source, + submittedAt: inspection.submittedAt, + } + : { submitted: false }, + orgLabel, + organizationColor, + organizationIds, + }; + }), + // 当前视图内出现过的负责机构,供筛选下拉使用 + organizations: [ + ...new Map( + occupancies + .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) + .map((o) => [ + o.responsibleOrganizationId, + { + id: o.responsibleOrganizationId, + name: o.responsibleOrganization.name, + color: o.responsibleOrganization.color || null, + }, + ]), + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name)), + }; + } + + private getChinaDate(now: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + } + + + async batchImport( + rows: { + roomNumber: string; + building?: string; + floor?: number; + capacity?: number; + roomType?: string; + rentalCategory?: string; + monthlyRate?: number; + }[], + ) { + let imported = 0; + let skipped = 0; + for (const row of rows) { + if (!row.roomNumber || !row.roomNumber.trim()) { + skipped++; + continue; + } + const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (exists) { + skipped++; + continue; + } + // 智能解析房间号 + const parsed = parseRoomNumber(row.roomNumber.trim()); + const room = await this.repo.save( + this.repo.create({ + roomNumber: row.roomNumber.trim(), + building: row.building?.trim() || parsed.building || undefined, + floor: row.floor ?? parsed.floor, + capacity: row.capacity ?? parsed.capacity ?? 4, + roomType: row.roomType || parsed.roomType || undefined, + rentalCategory: row.rentalCategory || undefined, + monthlyRate: row.monthlyRate ?? undefined, + }), + ); + await this.createDefaultBeds(room.id, room.capacity); + imported++; + } + return { + message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, + imported, + skipped, + }; + } + + // ── 床位管理 ── + + + async createDefaultBeds(roomId: number, capacity: number): Promise { + const count = Math.max(capacity ?? 0, 0); + if (count === 0) return; + const beds = Array.from({ length: count }, (_, index) => + this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), + ); + await this.bedRepo.save(beds); + } + +} diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index e075c20..dda7648 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -25,6 +25,7 @@ import { UpdateRoomInspectionDto } from './dto/room-inspection.dto'; import { RoomInspectionsService } from './room-inspections.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; @@ -64,16 +65,9 @@ export class RoomsController { @RequirePermission('room:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '批量恢复宿舍', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -86,23 +80,14 @@ export class RoomsController { @Body() dto: UpdateRoomInspectionDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.inspectionsService.submit( +roomId, date, dto.presentOccupancyIds, { id: req.user?.id, username: req.user?.username }, ); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍查寝', - action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', - targetId: +roomId, - targetType: 'room', - detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍查寝', action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', targetId: +roomId, targetType: 'room', detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`, }); return result.inspection; } @@ -285,16 +270,9 @@ export class RoomsController { @Post() @RequirePermission('room:create') async create(@Body() dto: CreateRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '添加宿舍', - detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, }); return result; } @@ -302,18 +280,9 @@ export class RoomsController { @Put(':id') @RequirePermission('room:edit') async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '编辑宿舍', - targetId: +id, - targetType: 'room', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto), }); return result; } @@ -321,17 +290,9 @@ export class RoomsController { @Delete(':id') @RequirePermission('room:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '归档宿舍', - targetId: +id, - targetType: 'room', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room', }); return result; } @@ -339,16 +300,29 @@ export class RoomsController { @Post('batch-delete') @RequirePermission('room:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '批量归档宿舍', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('room:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('room:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -356,17 +330,9 @@ export class RoomsController { @Put(':id/restore') @RequirePermission('room:edit') async restore(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '恢复宿舍', - targetId: +id, - targetType: 'room', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room', }); return result; } @@ -377,7 +343,7 @@ export class RoomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: { roomNumber: string; diff --git a/apps/server/src/rooms/rooms.module.ts b/apps/server/src/rooms/rooms.module.ts index c194366..cfafb48 100644 --- a/apps/server/src/rooms/rooms.module.ts +++ b/apps/server/src/rooms/rooms.module.ts @@ -8,6 +8,8 @@ import { Locker } from '../entities/locker.entity'; import { RoomInspection } from '../entities/room-inspection.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { RoomsService } from './rooms.service'; +import { RoomQueryService } from './room-query.service'; +import { RoomBedLockerService } from './room-bed-locker.service'; import { RoomsController } from './rooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { RoomInspectionsService } from './room-inspections.service'; @@ -26,7 +28,7 @@ import { RoomInspectionsService } from './room-inspections.service'; OperationLogsModule, ], controllers: [RoomsController], - providers: [RoomsService, RoomInspectionsService], + providers: [RoomsService, RoomInspectionsService, RoomQueryService, RoomBedLockerService], exports: [RoomsService, RoomInspectionsService], }) export class RoomsModule {} diff --git a/apps/server/src/rooms/rooms.purge.controller.spec.ts b/apps/server/src/rooms/rooms.purge.controller.spec.ts new file mode 100644 index 0000000..097a748 --- /dev/null +++ b/apps/server/src/rooms/rooms.purge.controller.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { RoomsController } from './rooms.controller'; + +describe('RoomsController purge routes', () => { + it('requires room:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.purge)).toEqual([ + 'room:purge', + ]); + expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.batchPurge)).toEqual([ + 'room:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除宿舍(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new RoomsController(service as never, { log } as never, {} as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '宿舍', action: '永久删除宿舍', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/rooms/rooms.purge.spec.ts b/apps/server/src/rooms/rooms.purge.spec.ts new file mode 100644 index 0000000..bfddc88 --- /dev/null +++ b/apps/server/src/rooms/rooms.purge.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { RoomsService } from './rooms.service'; + +describe('RoomsService.purge', () => { + const createService = (overrides?: { + room?: Record; + occupancyCount?: number; + expenseCount?: number; + }) => { + const room = { id: 1, roomNumber: '101', status: 'archived', ...overrides?.room }; + const repo = { + findOne: jest.fn().mockResolvedValue(room), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([room]), + }; + const occRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) }; + const roomExpRepo = { count: jest.fn().mockResolvedValue(overrides?.expenseCount ?? 0) }; + const service = new RoomsService( + repo as never, + occRepo as never, + roomExpRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return { service, repo, occRepo, roomExpRepo }; + }; + + it('rejects rooms that are not archived', async () => { + const { service, repo } = createService({ room: { status: 'available' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档宿舍可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects rooms referenced by occupancies or expenses', async () => { + const withOccupancy = createService({ occupancyCount: 1 }); + await expect(withOccupancy.service.purge(1)).rejects.toThrow( + new BadRequestException('该宿舍存在入住记录,无法永久删除'), + ); + expect(withOccupancy.repo.delete).not.toHaveBeenCalled(); + + const withExpense = createService({ expenseCount: 1 }); + await expect(withExpense.service.purge(1)).rejects.toThrow( + new BadRequestException('该宿舍存在宿舍费用,无法永久删除'), + ); + expect(withExpense.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived room with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除宿舍(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge skips referenced rooms', async () => { + const { service, repo, occRepo } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, roomNumber: '101', status: 'archived' }, + { id: 2, roomNumber: '102', status: 'archived' }, + ]); + occRepo.count + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + const result = await service.batchPurge([1, 2]); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 913390b..d0fd654 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -1,14 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - DataSource, - Repository, - Like, - IsNull, - Not, - In, - LessThanOrEqual, -} from 'typeorm'; +import { DataSource, Repository, IsNull, Not, In } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; @@ -19,26 +11,9 @@ import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; import { RoomInspectionsService } from './room-inspections.service'; -import { occupancyWhereOnDate } from './room-occupancy-date'; - -interface AgentRoomRow { - id: string | number; - roomNumber: string; - building: string | null; - floor: string | number | null; - capacity: string | number; - roomType: string | null; - status: string; - occupiedBeds: string | number; -} - -interface AgentRoomOccupancyRow { - roomId: string | number; - roomNumber: string; - building: string | null; - capacity: string | number; - occupiedBeds: string | number; -} +import { RoomQueryService } from './room-query.service'; +import { RoomBedLockerService } from './room-bed-locker.service'; +import { parseRoomNumber } from './room-number'; @Injectable() export class RoomsService { @@ -50,8 +25,24 @@ export class RoomsService { @InjectRepository(Locker) private lockerRepo: Repository, private dataSource: DataSource, private readonly inspectionsService: RoomInspectionsService, + @Optional() private queryService?: RoomQueryService, + @Optional() private beds?: RoomBedLockerService, ) {} + private get queries(): RoomQueryService { + if (!this.queryService) { + this.queryService = new RoomQueryService(this.repo, this.occRepo, this.bedRepo, this.inspectionsService); + } + return this.queryService; + } + + private get bedOps(): RoomBedLockerService { + if (!this.beds) { + this.beds = new RoomBedLockerService(this.repo, this.bedRepo, this.lockerRepo); + } + return this.beds; + } + /** * 智能解析房间号,自动推导楼栋、楼层、宿舍类型 * "4-102" → building:"4号楼", floor:1, roomType:"四人间" @@ -59,42 +50,8 @@ export class RoomsService { * "3-301" → building:"3号楼", floor:3, roomType:"四人间" * "8-102" → building:"8号楼", floor:1, roomType:"爆改房" */ - static parseRoomNumber(roomNumber: string): { - building?: string; - floor?: number; - roomType?: string; - capacity?: number; - } { - const cleaned = roomNumber.replace(/[((].*?[))]/g, '').trim(); - // 家庭房: X-Y-ZZZ 格式 - const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/); - if (familyMatch) { - const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; - const roomPart = familyMatch[3]; - const rawFloor = parseInt(roomPart.charAt(0), 10); - const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; - return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; - } - // 标准: X-YZZ 格式 - const stdMatch = cleaned.match(/^(\d+)-(\d+)$/); - if (stdMatch) { - const bldgNum = stdMatch[1]; - const roomPart = stdMatch[2]; - const rawFloor = parseInt(roomPart.charAt(0), 10); - const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; - const building = `${bldgNum}号楼`; - let roomType = '四人间'; - let capacity = 4; - if (bldgNum === '2') { - roomType = '单人间'; - capacity = 1; - } else if (bldgNum === '8') { - roomType = '爆改房'; - capacity = 2; - } - return { building, floor, roomType, capacity }; - } - return { capacity: 4, roomType: '四人间' }; + static parseRoomNumber(roomNumber: string) { + return parseRoomNumber(roomNumber); } async findAll(query?: { building?: string; includeArchived?: boolean }) { @@ -104,59 +61,6 @@ export class RoomsService { return this.repo.find({ where, order: { roomNumber: 'ASC' } }); } - async agentSearchRooms(query: { keyword?: string; building?: string; status?: string; limit?: number }) { - const qb = this.repo - .createQueryBuilder('room') - .leftJoin( - Occupancy, - 'occupancy', - 'occupancy.roomId = room.id AND occupancy.checkOutDate IS NULL', - ) - .select('room.id', 'id') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.floor', 'floor') - .addSelect('room.capacity', 'capacity') - .addSelect('room.roomType', 'roomType') - .addSelect('room.status', 'status') - .addSelect('COUNT(occupancy.id)', 'occupiedBeds') - .where('room.status != :archived', { archived: 'archived' }); - if (query.keyword) qb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); - if (query.building) qb.andWhere('room.building = :building', { building: query.building }); - if (query.status) qb.andWhere('room.status = :status', { status: query.status }); - const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 20).getRawMany(); - return rows.map((row) => ({ - ...row, - id: Number(row.id), floor: row.floor == null ? null : Number(row.floor), - capacity: Number(row.capacity), occupiedBeds: Number(row.occupiedBeds || 0), - })); - } - - async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { - const targetDate = query.date || this.getChinaDate(new Date()); - const qb = this.repo - .createQueryBuilder('room') - .leftJoin( - Occupancy, - 'occupancy', - 'occupancy.roomId = room.id AND occupancy.checkInDate <= :targetDate AND (occupancy.checkOutDate IS NULL OR occupancy.checkOutDate > :targetDate)', - { targetDate }, - ) - .select('room.id', 'roomId') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.capacity', 'capacity') - .addSelect('COUNT(occupancy.id)', 'occupiedBeds') - .where('room.status != :archived', { archived: 'archived' }); - if (query.building) qb.andWhere('room.building = :building', { building: query.building }); - const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 50).getRawMany(); - return rows.map((row) => { - const capacity = Number(row.capacity || 0); - const occupiedBeds = Number(row.occupiedBeds || 0); - return { date: targetDate, roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building, capacity, occupiedBeds, availableBeds: Math.max(0, capacity - occupiedBeds) }; - }); - } - async findOne(id: number) { const room = await this.repo.findOne({ where: { id } }); if (!room) throw new NotFoundException('宿舍不存在'); @@ -306,6 +210,54 @@ export class RoomsService { return { message: '已恢复' }; } + async purge(id: number) { + const room = await this.findOne(id); + if (room.status !== 'archived') + throw new BadRequestException('仅已归档宿舍可以永久删除,请先归档'); + const [occupancyCount, expenseCount] = await Promise.all([ + this.occRepo.count({ where: { roomId: id } }), + this.roomExpRepo.count({ where: { roomId: id } }), + ]); + if (occupancyCount > 0) throw new BadRequestException('该宿舍存在入住记录,无法永久删除'); + if (expenseCount > 0) throw new BadRequestException('该宿舍存在宿舍费用,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除宿舍(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('宿舍 ID 无效'); + } + const rooms = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const room of rooms) { + if (room.status !== 'archived') { + skipped.push(`${room.roomNumber}(未归档)`); + continue; + } + const [occupancyCount, expenseCount] = await Promise.all([ + this.occRepo.count({ where: { roomId: room.id } }), + this.roomExpRepo.count({ where: { roomId: room.id } }), + ]); + if (occupancyCount > 0 || expenseCount > 0) { + skipped.push(`${room.roomNumber}(存在关联数据)`); + continue; + } + await this.repo.delete(room.id); + deleted.push(room.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 间;${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 间宿舍(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchRestore(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的宿舍'); @@ -329,147 +281,16 @@ export class RoomsService { } return { message: `已批量恢复 ${restored} 间宿舍`, restored, skipped }; } - - async getRoomVisual(asOf?: string) { - // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 - const isHistorical = !!asOf; - const targetDate = asOf || this.getChinaDate(new Date()); - - // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 - const rooms = await this.repo.find({ - where: isHistorical ? {} : { status: Not('archived') }, - order: { building: 'ASC', roomNumber: 'ASC' }, - }); - - const occupancies = await this.occRepo.find({ - where: occupancyWhereOnDate(targetDate), - relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'], - order: { checkInDate: 'ASC' }, - }); - - // 按roomId分组入住记录 - const occMap = new Map(); - // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 - const refTime = new Date(targetDate).getTime(); - for (const occ of occupancies) { - if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); - const checkIn = new Date(occ.checkInDate); - const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); - occMap.get(occ.roomId)!.push({ - studentId: occ.studentId, - occupancyId: occ.id, - studentName: occ.student?.name || '未知', - bedId: occ.bedId ?? null, - bedNumber: occ.bed?.bedNumber || null, - checkInDate: occ.checkInDate, - billingStartDate: occ.billingStartDate, - days, - organization: occ.student?.organization?.name || null, - supervisor: occ.student?.supervisor || null, - organizationId: occ.responsibleOrganizationId || null, - organizationName: occ.responsibleOrganization?.name || null, - organizationColor: occ.responsibleOrganization?.color || null, - }); - } - - // 获取各楼栋列表 - const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))]; - - // 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。 - const visibleRooms = isHistorical - ? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0) - : rooms; - - // 批量获取床位统计 - const allBeds = await this.bedRepo.find({ - where: { roomId: In(visibleRooms.map((r) => r.id)) }, - }); - const bedMap = new Map(); - for (const bed of allBeds) { - if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 }); - const entry = bedMap.get(bed.roomId)!; - entry.total++; - if (bed.status === 'occupied') entry.occupied++; - } - - const inspectionMap = await this.inspectionsService.getByRoomsAndDate( - visibleRooms.map((room) => room.id), - targetDate, - ); - - return { - buildings, - rooms: visibleRooms.map((room) => { - const occ = occMap.get(room.id) || []; - const inspection = inspectionMap.get(room.id); - const inspectionByOccupancyId = new Map( - (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), - ); - const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; - let orgLabel: string | null = null; - if (orgs.length > 0 && occ.length > 0) { - const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); - orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; - } - const organizationColors = [ - ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), - ]; - const organizationColor: string | null = - organizationColors.length === 1 ? organizationColors[0] : null; - const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; - return { - id: room.id, - roomNumber: room.roomNumber, - building: room.building, - floor: room.floor, - capacity: room.capacity, - status: room.status, - currentCount: occ.length, - totalBeds: bedMap.get(room.id)?.total ?? 0, - occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, - occupants: occ.map((occupant) => ({ - ...occupant, - inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null, - })), - inspection: inspection - ? { - submitted: true, - inspectorId: inspection.inspectorId, - inspectorName: inspection.inspectorName, - source: inspection.source, - submittedAt: inspection.submittedAt, - } - : { submitted: false }, - orgLabel, - organizationColor, - organizationIds, - }; - }), - // 当前视图内出现过的负责机构,供筛选下拉使用 - organizations: [ - ...new Map( - occupancies - .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) - .map((o) => [ - o.responsibleOrganizationId, - { - id: o.responsibleOrganizationId, - name: o.responsibleOrganization.name, - color: o.responsibleOrganization.color || null, - }, - ]), - ).values(), - ].sort((a, b) => a.name.localeCompare(b.name)), - }; + async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) { + return this.queries.agentSearchRooms(query); } - private getChinaDate(now: Date): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(now); + async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { + return this.queries.agentGetRoomOccupancySummary(query); + } + + async getRoomVisual(asOf?: string) { + return this.queries.getRoomVisual(asOf); } async batchImport( @@ -483,212 +304,63 @@ export class RoomsService { monthlyRate?: number; }[], ) { - let imported = 0; - let skipped = 0; - for (const row of rows) { - if (!row.roomNumber || !row.roomNumber.trim()) { - skipped++; - continue; - } - const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (exists) { - skipped++; - continue; - } - // 智能解析房间号 - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - const room = await this.repo.save( - this.repo.create({ - roomNumber: row.roomNumber.trim(), - building: row.building?.trim() || parsed.building || undefined, - floor: row.floor ?? parsed.floor, - capacity: row.capacity ?? parsed.capacity ?? 4, - roomType: row.roomType || parsed.roomType || undefined, - rentalCategory: row.rentalCategory || undefined, - monthlyRate: row.monthlyRate ?? undefined, - }), - ); - await this.createDefaultBeds(room.id, room.capacity); - imported++; - } - return { - message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, - imported, - skipped, - }; + return this.queries.batchImport(rows); } - // ── 床位管理 ── - - async getRoomBeds(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); - } - - async getRoomAvailableBeds(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.bedRepo.find({ - where: { roomId, status: 'available' }, - order: { bedNumber: 'ASC' }, - }); - } - - async createBed(roomId: number, dto: CreateBedDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); - await this.assertCanAddBeds(room, 1); - const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); - if (existing) throw new BadRequestException('该床位编号已存在'); - const bed = this.bedRepo.create({ ...dto, roomId }); - return this.bedRepo.save(bed); - } - - async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { - const bed = await this.bedRepo.findOne({ where: { id, roomId } }); - if (!bed) throw new NotFoundException('床位不存在'); - // 不允许将 occupied 的床位改为 maintenance - if (dto.status === 'maintenance' && bed.status === 'occupied') { - throw new BadRequestException('该床位有人入住,请先退宿'); - } - // 编号唯一性检查 - if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { - const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); - if (dup) throw new BadRequestException('该床位编号已存在'); - } - Object.assign(bed, dto); - return this.bedRepo.save(bed); - } - - async deleteBed(roomId: number, id: number): Promise { - const bed = await this.bedRepo.findOne({ where: { id, roomId } }); - if (!bed) throw new NotFoundException('床位不存在'); - if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档'); - if (bed.status === 'archived') throw new BadRequestException('该床位已归档'); - await this.bedRepo.update(id, { status: 'archived' }); - } - - async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); - const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); - this.assertCanAddBedsFromCount(room, existing.length, dto.count); - const numbers = existing.map((b) => { - const match = b.bedNumber.match(/^\d+/); - return match ? parseInt(match[0]) : 0; - }); - const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; - const beds: Bed[] = []; - for (let i = 0; i < dto.count; i++) { - beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); - } - return this.bedRepo.save(beds); - } - - private async createDefaultBeds(roomId: number, capacity: number): Promise { - const count = Math.max(capacity ?? 0, 0); - if (count === 0) return; - const beds = Array.from({ length: count }, (_, index) => - this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), - ); - await this.bedRepo.save(beds); + private createDefaultBeds(roomId: number, capacity: number): Promise { + return this.queries.createDefaultBeds(roomId, capacity); } private getNextBedNumber(beds: Pick[]): number { - const numbers = beds.map((bed) => { - const match = bed.bedNumber.match(/^\d+/); - return match ? parseInt(match[0], 10) : 0; - }); - return numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + return this.bedOps.getNextBedNumber(beds); } - private async assertCanAddBeds(room: Room, count: number): Promise { - const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); - this.assertCanAddBedsFromCount(room, existingCount, count); + async getRoomBeds(roomId: number): Promise { + return this.bedOps.getRoomBeds(roomId); } - private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { - const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); - if (count > remaining) { - throw new BadRequestException( - `床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`, - ); - } + async getRoomAvailableBeds(roomId: number): Promise { + return this.bedOps.getRoomAvailableBeds(roomId); } - // ── 柜子管理 ── + async createBed(roomId: number, dto: CreateBedDto): Promise { + return this.bedOps.createBed(roomId, dto); + } + + async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { + return this.bedOps.updateBed(roomId, id, dto); + } + + async deleteBed(roomId: number, id: number): Promise { + return this.bedOps.deleteBed(roomId, id); + } + + async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { + return this.bedOps.batchCreateBeds(roomId, dto); + } async getRoomLockers(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } }); + return this.bedOps.getRoomLockers(roomId); } async getRoomAvailableLockers(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.lockerRepo.find({ - where: { roomId, status: 'available' }, - order: { lockerNumber: 'ASC' }, - }); + return this.bedOps.getRoomAvailableLockers(roomId); } async createLocker(roomId: number, dto: CreateLockerDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.findOne({ - where: { roomId, lockerNumber: dto.lockerNumber }, - }); - if (existing) throw new BadRequestException('该柜子编号已存在'); - const locker = this.lockerRepo.create({ ...dto, roomId }); - return this.lockerRepo.save(locker); + return this.bedOps.createLocker(roomId, dto); } async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { - const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); - if (!locker) throw new NotFoundException('柜子不存在'); - if (dto.status === 'maintenance' && locker.status === 'occupied') { - throw new BadRequestException('该柜子有人占用,请先释放'); - } - if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { - const dup = await this.lockerRepo.findOne({ - where: { roomId, lockerNumber: dto.lockerNumber }, - }); - if (dup) throw new BadRequestException('该柜子编号已存在'); - } - Object.assign(locker, dto); - return this.lockerRepo.save(locker); + return this.bedOps.updateLocker(roomId, id, dto); } async deleteLocker(roomId: number, id: number): Promise { - const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); - if (!locker) throw new NotFoundException('柜子不存在'); - if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档'); - if (locker.status === 'archived') throw new BadRequestException('该柜子已归档'); - await this.lockerRepo.update(id, { status: 'archived' }); + return this.bedOps.deleteLocker(roomId, id); } async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.find({ - where: { roomId, status: Not('archived') }, - order: { lockerNumber: 'ASC' }, - }); - const numbers = existing.map((b) => { - const match = b.lockerNumber.match(/^\d+/); - return match ? parseInt(match[0]) : 0; - }); - const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; - const lockers: Locker[] = []; - for (let i = 0; i < dto.count; i++) { - lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); - } - return this.lockerRepo.save(lockers); + return this.bedOps.batchCreateLockers(roomId, dto); } -} + +} \ No newline at end of file diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts new file mode 100644 index 0000000..d146f11 --- /dev/null +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ClassSchedule } from '../entities'; +import type { WeeklyViewQueryDto } from './dto/schedule.dto'; + +const ACTIVE_SCHEDULE_STATUS = 'active'; + +@Injectable() +export class ScheduleQueriesService { + constructor( + @InjectRepository(ClassSchedule) + private readonly scheduleRepo: Repository, + ) {} + + maskScheduleOccupancy(schedule: ClassSchedule) { + return { + id: null, + classId: null, + classroomId: schedule.classroomId, + weekDay: schedule.weekDay, + startTime: schedule.startTime, + endTime: schedule.endTime, + attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, + startDate: schedule.startDate, + endDate: schedule.endDate, + subject: '已占用', + teacherId: null, + scheduleType: schedule.scheduleType, + status: schedule.status, + notes: null, + canViewDetails: false, + }; + } + + + async agentSearchSchedules( + accessibleClassIds: number[] | undefined, + query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, + ): Promise< + { + id: number; + classId: number | null; + className: string | null; + classroomId: number; + classroomName: string | null; + weekDay: number; + startTime: string; + endTime: string; + subject: string; + teacherName: string | null; + startDate: string; + endDate: string; + scheduleType: string; + status: string; + }[] + > { + if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) { + return []; + } + if (accessibleClassIds && accessibleClassIds.length === 0) { + return []; + } + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoin('cs.class', 'class') + .leftJoin('cs.classroom', 'classroom') + .leftJoin('cs.teacher', 'teacher') + .select([ + 'cs.id', + 'cs.classId', + 'cs.classroomId', + 'cs.weekDay', + 'cs.startTime', + 'cs.endTime', + 'cs.subject', + 'cs.teacherId', + 'cs.startDate', + 'cs.endDate', + 'cs.scheduleType', + 'cs.status', + 'class.name', + 'classroom.name', + 'teacher.name', + ]) + .where('cs.status = :active', { active: 'active' }); + + if (query?.classroomId) { + qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query?.classId) { + qb.andWhere('cs.classId = :classId', { classId: query.classId }); + } + if (accessibleClassIds) { + qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); + } + if (query?.weekDay) { + qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); + } + + const rows = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) + .getRawMany>(); + return rows.map((row) => ({ + id: Number(row.cs_id), + classId: row.cs_class_id == null ? null : Number(row.cs_class_id), + className: row.class_name == null ? null : String(row.class_name), + classroomId: Number(row.cs_classroom_id), + classroomName: row.classroom_name == null ? null : String(row.classroom_name), + weekDay: Number(row.cs_week_day), + startTime: String(row.cs_start_time), + endTime: String(row.cs_end_time), + subject: String(row.cs_subject), + teacherName: row.teacher_name == null ? null : String(row.teacher_name), + startDate: String(row.cs_start_date), + endDate: String(row.cs_end_date), + scheduleType: String(row.cs_schedule_type), + status: String(row.cs_status), + })); + } + + + async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { + const qb = this.scheduleRepo.createQueryBuilder('cs'); + if (query.classroomId) { + qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query.startDate) { + qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); + } + + const schedules = await qb + .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .getMany(); + + const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; + const visibleSchedules = schedules.map((schedule) => { + const canViewDetails = + allowedClassIds === null || + (schedule.classId !== null && allowedClassIds.has(schedule.classId)); + if (canViewDetails) return { ...schedule, canViewDetails: true }; + + // Other classes remain visible only as a room/time occupancy block. + // Do not expose class, subject, teacher, notes, or internal record IDs. + return this.maskScheduleOccupancy(schedule); + }); + + // Group by classroomId → weekDay + const matrix: Record> = {}; + for (const schedule of visibleSchedules) { + if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; + if (!matrix[schedule.classroomId][schedule.weekDay]) + matrix[schedule.classroomId][schedule.weekDay] = []; + matrix[schedule.classroomId][schedule.weekDay].push(schedule); + } + + return matrix; + } + + + async getClassroomOccupancy(classroomId: number, date?: string) { + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .where('cs.classroomId = :classroomId', { classroomId }) + .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) + .andWhere('cs.scheduleType IN (:...scheduleTypes)', { + scheduleTypes: ['INTERNAL', 'RENTAL'], + }); + + if (date) { + qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date }); + } + + return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany(); + } +} diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts index 4d201f8..4a06585 100644 --- a/apps/server/src/schedules/schedules.controller.ts +++ b/apps/server/src/schedules/schedules.controller.ts @@ -22,6 +22,7 @@ import { } from './dto/schedule.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; @@ -170,17 +171,7 @@ export class SchedulesController { dto.startDate, dto.endDate, ); - const teacherIds = [ - ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), - ]; - if (teacherIds.length > 0) { - void this.notificationsService.create({ - recipientIds: teacherIds, - type: NotificationType.SCHEDULE_CONFLICT, - title: '排课冲突', - content: `教室${dto.classroomId} 周${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`, - }); - } + this.notifyScheduleConflict(conflicts, dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, ''); } catch { // Best-effort conflict notification must not hide the original conflict. } @@ -189,6 +180,27 @@ export class SchedulesController { } } + private notifyScheduleConflict( + conflicts: Array<{ teacherId: number | null }>, + classroomId: number, + weekDay: number, + startTime: string, + endTime: string, + suffix: string, + ): void { + const teacherIds = [ + ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), + ]; + if (teacherIds.length > 0) { + void this.notificationsService.create({ + recipientIds: teacherIds, + type: NotificationType.SCHEDULE_CONFLICT, + title: '排课冲突', + content: `教室${classroomId} 周${weekDay} ${startTime}-${endTime} ${suffix}与已有排课冲突`, + }); + } + } + @Put(':id') @RequirePermission('schedule:edit') async update( @@ -226,17 +238,7 @@ export class SchedulesController { existing.startDate, existing.endDate, ); - const teacherIds = [ - ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), - ]; - if (teacherIds.length > 0) { - void this.notificationsService.create({ - recipientIds: teacherIds, - type: NotificationType.SCHEDULE_CONFLICT, - title: '排课冲突', - content: `教室${existing.classroomId} 周${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`, - }); - } + this.notifyScheduleConflict(conflicts, existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, ' (更新)'); } catch { // Best-effort conflict notification must not hide the original conflict. } @@ -251,18 +253,10 @@ export class SchedulesController { @Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); await this.getAuthorizedSchedule(+id, req as { user: RequestUser }); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '排课管理', - action: '停用排课', - targetId: +id, - targetType: 'class-schedule', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '排课管理', action: '停用排课', targetId: +id, targetType: 'class-schedule', }); return result; } diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts index bf4041e..6d28106 100644 --- a/apps/server/src/schedules/schedules.module.ts +++ b/apps/server/src/schedules/schedules.module.ts @@ -9,6 +9,7 @@ import { AttendanceSession, } from '../entities'; import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { SchedulesController } from './schedules.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -27,7 +28,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; NotificationsModule, ], controllers: [SchedulesController], - providers: [SchedulesService], + providers: [SchedulesService, ScheduleQueriesService], exports: [SchedulesService], }) export class SchedulesModule {} diff --git a/apps/server/src/schedules/schedules.scope.spec.ts b/apps/server/src/schedules/schedules.scope.spec.ts index 1522cac..6dbe04a 100644 --- a/apps/server/src/schedules/schedules.scope.spec.ts +++ b/apps/server/src/schedules/schedules.scope.spec.ts @@ -1,4 +1,5 @@ import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; const createQb = () => ({ andWhere: jest.fn().mockReturnThis(), @@ -20,6 +21,7 @@ function serviceWithAssignments(assignments: number[]) { .fn() .mockResolvedValue(assignments.map((classId) => ({ classId, userId: 7 }))), }; + const queries = new ScheduleQueriesService(scheduleRepo as never); const service = new SchedulesService( scheduleRepo as never, {} as never, @@ -27,6 +29,7 @@ function serviceWithAssignments(assignments: number[]) { {} as never, classTeacherRepo as never, {} as never, + queries, ); return { service, qb, scheduleRepo }; } @@ -40,6 +43,9 @@ describe('SchedulesService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); await service.findAll({}, [3, 5]); @@ -57,6 +63,9 @@ describe('SchedulesService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); await expect(service.findAll({}, [])).resolves.toEqual([]); @@ -132,11 +141,15 @@ describe('SchedulesService — shared classroom occupancy visibility', () => { notes: '其他班备注', }, ]); + const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; const service = new SchedulesService( - { createQueryBuilder: jest.fn().mockReturnValue(qb) } as never, + scheduleRepo as never, {} as never, {} as never, {} as never, + {} as never, + {} as never, + new ScheduleQueriesService(scheduleRepo as never), ); const result = await service.getWeeklyView({}, [3]); diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index 771d18a..ab9f4c2 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { BadRequestException, ConflictException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Class } from '../entities/class.entity'; @@ -38,6 +39,7 @@ describe('SchedulesService — getLookups', () => { const module = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) }, @@ -74,6 +76,7 @@ describe('SchedulesService — checkConflict', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, @@ -223,6 +226,7 @@ describe('SchedulesService — getClassroomOccupancy', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, @@ -303,6 +307,7 @@ describe('SchedulesService — remove/update status', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepoMock, @@ -445,6 +450,7 @@ describe('SchedulesService — range boundaries', () => { const makeService = () => { const scheduleRepo = { create: jest.fn() }; return { + queries: new ScheduleQueriesService(scheduleRepo as never), service: new SchedulesService( scheduleRepo as never, {} as never, diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index cb3d14f..f2a6963 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -6,7 +6,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ClassSchedule, Class, @@ -16,6 +16,7 @@ import { ClassTeacher, AttendanceSession, } from '../entities'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { CreateScheduleDto, UpdateScheduleDto, @@ -27,7 +28,10 @@ const SCHEDULE_GAP_MINUTES = 10; const ACTIVE_SCHEDULE_STATUS = 'active'; const INACTIVE_SCHEDULE_STATUSES = ['inactive', 'cancelled'] as const; type ScheduleStatus = typeof ACTIVE_SCHEDULE_STATUS | (typeof INACTIVE_SCHEDULE_STATUSES)[number]; -const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ACTIVE_SCHEDULE_STATUS, ...INACTIVE_SCHEDULE_STATUSES]; +const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ + ACTIVE_SCHEDULE_STATUS, + ...INACTIVE_SCHEDULE_STATUSES, +]; function shiftTime(time: string, minutes: number): string { const [hours, minutePart] = time.split(':').map(Number); @@ -50,6 +54,7 @@ export class SchedulesService { private readonly classTeacherRepo: Repository, @InjectRepository(AttendanceSession) private readonly attendanceSessionRepo: Repository, + private readonly queries: ScheduleQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -64,26 +69,6 @@ export class SchedulesService { if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课'); } - maskScheduleOccupancy(schedule: ClassSchedule) { - return { - id: null, - classId: null, - classroomId: schedule.classroomId, - weekDay: schedule.weekDay, - startTime: schedule.startTime, - endTime: schedule.endTime, - attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, - startDate: schedule.startDate, - endDate: schedule.endDate, - subject: '已占用', - teacherId: null, - scheduleType: schedule.scheduleType, - status: schedule.status, - notes: null, - canViewDetails: false, - }; - } - async getLookups(accessibleClassIds?: number[]) { const classes = accessibleClassIds ? accessibleClassIds.length > 0 @@ -133,95 +118,6 @@ export class SchedulesService { * Agent tool: 查询当前用户有权查看的排课,返回白名单字段。 * 教师范围按班级授课关系过滤。 */ - async agentSearchSchedules( - userId: number, - canManageAll: boolean, - query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, - ): Promise< - { - id: number; - classId: number | null; - className: string | null; - classroomId: number; - classroomName: string | null; - weekDay: number; - startTime: string; - endTime: string; - subject: string; - teacherName: string | null; - startDate: string; - endDate: string; - scheduleType: string; - status: string; - }[] - > { - const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); - if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) { - return []; - } - if (accessibleClassIds && accessibleClassIds.length === 0) { - return []; - } - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .leftJoin('cs.class', 'class') - .leftJoin('cs.classroom', 'classroom') - .leftJoin('cs.teacher', 'teacher') - .select([ - 'cs.id', - 'cs.classId', - 'cs.classroomId', - 'cs.weekDay', - 'cs.startTime', - 'cs.endTime', - 'cs.subject', - 'cs.teacherId', - 'cs.startDate', - 'cs.endDate', - 'cs.scheduleType', - 'cs.status', - 'class.name', - 'classroom.name', - 'teacher.name', - ]) - .where('cs.status = :active', { active: 'active' }); - - if (query?.classroomId) { - qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); - } - if (query?.classId) { - qb.andWhere('cs.classId = :classId', { classId: query.classId }); - } - if (accessibleClassIds) { - qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); - } - if (query?.weekDay) { - qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); - } - - const rows = await qb - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); - return rows.map((row) => ({ - id: Number(row.cs_id), - classId: row.cs_class_id == null ? null : Number(row.cs_class_id), - className: row.class_name == null ? null : String(row.class_name), - classroomId: Number(row.cs_classroom_id), - classroomName: row.classroom_name == null ? null : String(row.classroom_name), - weekDay: Number(row.cs_week_day), - startTime: String(row.cs_start_time), - endTime: String(row.cs_end_time), - subject: String(row.cs_subject), - teacherName: row.teacher_name == null ? null : String(row.teacher_name), - startDate: String(row.cs_start_date), - endDate: String(row.cs_end_date), - scheduleType: String(row.cs_schedule_type), - status: String(row.cs_status), - })); - } - async getClassTeachers(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId }, @@ -331,6 +227,8 @@ export class SchedulesService { const weekDay = dto.weekDay ?? existing.weekDay; const startTime = dto.startTime ?? existing.startTime; const endTime = dto.endTime ?? existing.endTime; + + const startDate = dto.startDate ?? existing.startDate; const endDate = dto.endDate ?? existing.endDate; this.assertValidScheduleRange(startTime, endTime, startDate, endDate); @@ -361,6 +259,26 @@ export class SchedulesService { return this.findOne(id); } + maskScheduleOccupancy(schedule: ClassSchedule) { + return this.queries.maskScheduleOccupancy(schedule); + } + + async agentSearchSchedules( + userId: number, + canManageAll: boolean, + query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, + ) { + const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); + return this.queries.agentSearchSchedules(accessibleClassIds, query); + } + + async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { + return this.queries.getWeeklyView(query, accessibleClassIds); + } + + async getClassroomOccupancy(classroomId: number, date?: string) { + return this.queries.getClassroomOccupancy(classroomId, date); + } async remove(id: number) { const schedule = await this.scheduleRepo.findOne({ where: { id } }); if (!schedule) throw new NotFoundException('排课记录不存在'); @@ -421,61 +339,4 @@ export class SchedulesService { return conflicts; } - async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { - const qb = this.scheduleRepo.createQueryBuilder('cs'); - if (query.classroomId) { - qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); - } - if (query.startDate) { - qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); - } - - const schedules = await qb - .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .getMany(); - - const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; - const visibleSchedules = schedules.map((schedule) => { - const canViewDetails = - allowedClassIds === null || - (schedule.classId !== null && allowedClassIds.has(schedule.classId)); - if (canViewDetails) return { ...schedule, canViewDetails: true }; - - // Other classes remain visible only as a room/time occupancy block. - // Do not expose class, subject, teacher, notes, or internal record IDs. - return this.maskScheduleOccupancy(schedule); - }); - - // Group by classroomId → weekDay - const matrix: Record> = {}; - for (const schedule of visibleSchedules) { - if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; - if (!matrix[schedule.classroomId][schedule.weekDay]) - matrix[schedule.classroomId][schedule.weekDay] = []; - matrix[schedule.classroomId][schedule.weekDay].push(schedule); - } - - return matrix; - } - - async getClassroomOccupancy(classroomId: number, date?: string) { - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .where('cs.classroomId = :classroomId', { classroomId }) - .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) - .andWhere('cs.scheduleType IN (:...scheduleTypes)', { - scheduleTypes: ['INTERNAL', 'RENTAL'], - }); - - if (date) { - qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date }); - } - - return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany(); - } } diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts new file mode 100644 index 0000000..34f446b --- /dev/null +++ b/apps/server/src/students/students.agent.service.ts @@ -0,0 +1,233 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { ClassStudent } from '../entities/class-student.entity'; +import type { StudentAccessScope } from './student-access-scope'; + +@Injectable() +export class StudentsAgentService { + /** + * Whitelisted output type for agent student searches. + * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone. + */ + private static readonly AGENT_STUDENT_SELECT = [ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'organization.name', + ] as const; + + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(ClassStudent) + private readonly classStudentRepo: Repository, + ) {} + + /** + * Search students with SQL-enforced scope, field whitelist, and limit. + * + * @param scope — data-range discriminator (manageAll or teacher). + * @param query — optional keyword, classId, organizationId, limit. + * @returns formatted whitelist-only results with classIds. + */ + async agentSearchStudents( + scope: StudentAccessScope, + query?: { + keyword?: string; + classId?: number; + organizationId?: number; + limit?: number; + }, + ): Promise< + { + id: number; + name: string; + studentNo: string; + gender: string; + status: string; + organizationId: number; + organizationName: string; + classIds: number[]; + }[] + > { + const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); + + const qb = this.repo + .createQueryBuilder('student') + .distinct(true) + .select([ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'student.createdAt', + 'organization.name', + ]) + .leftJoin('student.organization', 'organization'); + + this.applyStudentScope(qb, scope, query?.classId); + + if (query?.keyword) { + qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); + } + if (query?.organizationId) { + qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId }); + } + + qb.orderBy('student.createdAt', 'DESC').take(limit); + + const rows: Record[] = await qb.getRawMany(); + if (rows.length === 0) return []; + + // Second bounded query: classIds only for the returned student ids. + // For teacher scope, the class filter MUST be re-applied so the + // teacher only sees classIds they are assigned to. + const studentIds = rows.map((r) => r.student_id as number); + const csQb = this.classStudentRepo + .createQueryBuilder('cs') + .select(['cs.studentId', 'cs.classId']) + .where('cs.student_id IN (:...ids)', { ids: studentIds }) + .andWhere('cs.status = :status', { status: 'active' }); + + if (scope.type === 'teacher') { + csQb.andWhere( + 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', + { scopeTeacherUserId: scope.userId }, + ); + } + + const classRows = await csQb.getRawMany(); + + const classMap = new Map(); + for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { + const sid = cr.cs_student_id; + if (!classMap.has(sid)) classMap.set(sid, []); + classMap.get(sid)!.push(cr.cs_class_id); + } + + return rows.map((r) => ({ + id: r.student_id as number, + name: r.student_name as string, + studentNo: (r.student_student_no as string) ?? '', + gender: (r.student_gender as string) ?? '', + status: r.student_status as string, + organizationId: r.student_organization_id as number, + organizationName: (r.organization_name as string) ?? '', + classIds: classMap.get(r.student_id as number) ?? [], + })); + } + + /** + * Get single student basic info with SQL-enforced scope + whitelist. + * Returns `null` for students out of scope or non-existent (no leak). + */ + async agentGetStudentBasic( + scope: StudentAccessScope, + studentId: number, + ): Promise<{ + id: number; + name: string; + studentNo: string; + gender: string; + status: string; + organizationId: number; + organizationName: string; + classIds: number[]; + } | null> { + const qb = this.repo + .createQueryBuilder('student') + .select([ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'organization.name', + ]) + .leftJoin('student.organization', 'organization') + .where('student.id = :studentId', { studentId }); + + this.applyStudentScope(qb, scope); + + const row = await qb.getRawOne(); + if (!row) return null; + + // For teacher scope, re-apply class filter so teacher only sees + // classIds they are assigned to (not ALL active classIds of the student). + const csQb = this.classStudentRepo + .createQueryBuilder('cs') + .select(['cs.classId']) + .where('cs.student_id = :studentId', { studentId }) + .andWhere('cs.status = :status', { status: 'active' }); + + if (scope.type === 'teacher') { + csQb.andWhere( + 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', + { scopeTeacherUserId: scope.userId }, + ); + } + + const classRows = await csQb.getRawMany(); + + return { + id: row.student_id as number, + name: row.student_name as string, + studentNo: (row.student_student_no as string) ?? '', + gender: (row.student_gender as string) ?? '', + status: row.student_status as string, + organizationId: row.student_organization_id as number, + organizationName: (row.organization_name as string) ?? '', + classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), + }; + } + + /** + * Apply data-range scope to a student QueryBuilder. + * + * - `manageAll`: no restriction. + * - `teacher`: INNER JOIN ClassStudent → active students in the + * teacher's assigned classes (via ClassTeacher). + * - When `classId` is provided, it is ANDed with the scope + * (intersection) — the model cannot widen access. + */ + private applyStudentScope( + qb: ReturnType, + scope: StudentAccessScope, + classId?: number, + ): void { + if (scope.type === 'manageAll') { + if (classId != null) { + qb.innerJoin( + 'class_student', + 'cs_scope', + 'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus', + { scopeClassId: classId, scopeCsStatus: 'active' }, + ); + } + return; + } + + // Teacher scope: active students in teacher's assigned classes + const teacherClause = + 'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' + + '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)'; + + qb.innerJoin('class_student', 'cs_scope', teacherClause, { + scopeTeacherUserId: scope.userId, + scopeCsStatus: 'active', + }); + + if (classId != null) { + qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId }); + } + } +} diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 28dc968..2e85695 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -12,7 +12,6 @@ import { Res, UseInterceptors, UploadedFile, - Inject, ParseIntPipe, UsePipes, ValidationPipe, @@ -20,17 +19,20 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Organization } from '../entities/organization.entity'; -import { ClassTeacher } from '../entities/class-teacher.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { StudentsService } from './students.service'; import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; -import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; -import type { AuthenticatedUser } from '../authorization'; +import { + AuthorizationService, + CaslAction, + SubjectName, + type AuthenticatedUser, +} from '../authorization'; import * as ExcelJS from 'exceljs'; import { createStudentImportTemplateWorkbook, @@ -79,27 +81,17 @@ export class StudentsController { @Get() @RequirePermission('student:view') - async findAll( - @Query() query: QueryStudentDto, - @Request() req: AuthenticatedRequest, - ) { + async findAll(@Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), ); - return this.service.findAll( - query, - classIds, - ); + return this.service.findAll(query, classIds); } @Get('export') @RequirePermission('student:export') - async exportExcel( - @Query() query: QueryStudentDto, - @Res() res?: Response, - @Request() req?: any, - ) { + async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), @@ -142,15 +134,8 @@ export class StudentsController { admittedMajor: result?.admittedMajor || '', }); } - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '导出学生', - detail: `导出 ${students.length} 名学生`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`, }); res!.setHeader( 'Content-Type', @@ -183,18 +168,9 @@ export class StudentsController { @Post() @RequirePermission('student:create') async create(@Body() dto: CreateStudentDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '新增学生', - targetId: result.id, - targetType: 'student', - detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, }); return result; } @@ -203,35 +179,23 @@ export class StudentsController { @RequirePermission('student:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '批量恢复学生', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @Put(':id') @RequirePermission('student:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); + async update( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateStudentDto, + @Request() req: any, + ) { const result = await this.service.update(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '编辑学生', - targetId: id, - targetType: 'student', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto), }); return result; } @@ -239,17 +203,9 @@ export class StudentsController { @Delete(':id') @RequirePermission('student:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '归档学生', - targetId: id, - targetType: 'student', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '归档学生', targetId: id, targetType: 'student', }); return result; } @@ -257,16 +213,29 @@ export class StudentsController { @Post('batch-delete') @RequirePermission('student:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '批量归档学生', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('student:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('student:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -274,17 +243,9 @@ export class StudentsController { @Put(':id/restore') @RequirePermission('student:edit') async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '恢复学生', - targetId: id, - targetType: 'student', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student', }); return result; } @@ -293,9 +254,8 @@ export class StudentsController { @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -309,14 +269,8 @@ export class StudentsController { } } const result = await this.service.batchImport(importData); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '导入学生', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '导入学生', detail: result.message, }); return result; } @@ -325,9 +279,8 @@ export class StudentsController { @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -339,14 +292,8 @@ export class StudentsController { } } const result = await this.service.matchImport(importData); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '更新已有学生资料', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '更新已有学生资料', detail: result.message, }); return result; } diff --git a/apps/server/src/students/students.import.service.ts b/apps/server/src/students/students.import.service.ts new file mode 100644 index 0000000..ef89cf4 --- /dev/null +++ b/apps/server/src/students/students.import.service.ts @@ -0,0 +1,314 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { Organization } from '../entities/organization.entity'; +import type { + ExamScoreImportRow, + LearningRecordImportRow, + StudentEnrollmentImportRow, + StudentImportRow, + StudentWorkbookImport, +} from './student-import'; +import { getHostOrganizationId } from './students.organization'; + +@Injectable() +export class StudentsImportService { + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(StudentProfile) private readonly profileRepo: Repository, + @InjectRepository(StudentEnrollment) + private readonly enrollmentRepo: Repository, + @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, + @InjectRepository(LearningRecord) + private readonly learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private readonly resultRepo: Repository, + @InjectRepository(Organization) private readonly organizationRepo: Repository, + ) {} + + async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); + let imported = 0; + let skipped = 0; + let archiveImported = 0; + for (const row of data.students) { + if (!row.name || !row.name.trim()) { + skipped++; + continue; + } + const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); + if (exists) { + skipped++; + continue; + } + const student = await this.repo.save( + this.repo.create({ + name: row.name.trim(), + studentNo: row.studentNo?.trim() || undefined, + phone: row.phone?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender || undefined, + ethnicity: row.ethnicity || undefined, + emergencyContact: row.emergencyContact || undefined, + emergencyPhone: row.emergencyPhone || undefined, + supervisor: row.supervisor || undefined, + organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)), + }), + ); + archiveImported += await this.importArchiveData(student.id, row, data); + imported++; + } + return { + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, + imported, + archiveImported, + skipped, + }; + } + + async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); + let matched = 0; + let skipped = 0; + let archiveImported = 0; + for (const row of data.students) { + // Match by phone first, then idNumber + let student = row.phone?.trim() + ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) + : null; + if (!student && row.idNumber?.trim()) { + student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); + } + if (!student) { + skipped++; + continue; + } + const updates: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' + | 'organizationId' + > + > = {}; + if (row.name?.trim()) updates.name = row.name.trim(); + if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); + if (row.phone?.trim()) updates.phone = row.phone.trim(); + if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); + if (row.gender) updates.gender = row.gender; + if (row.ethnicity) updates.ethnicity = row.ethnicity; + if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; + if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; + if (row.supervisor) updates.supervisor = row.supervisor; + if (row.organizationId) updates.organizationId = row.organizationId; + await this.repo.update(student.id, updates); + archiveImported += await this.importArchiveData(student.id, row, data); + matched++; + } + return { + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, + matched, + archiveImported, + skipped, + }; + } + + private normalizeImportData( + importData: StudentWorkbookImport | StudentImportRow[], + ): StudentWorkbookImport { + if (Array.isArray(importData)) { + return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; + } + return importData; + } + + private normalizePhone(phone?: string) { + return phone?.trim() || ''; + } + + private sameValue(left?: string | number | null, right?: string | number | null) { + return String(left ?? '').trim() === String(right ?? '').trim(); + } + + private hasProfileData(row: StudentImportRow) { + return [ + row.targetCollege, + row.targetMajor, + row.collegeSchool, + row.collegeMajor, + row.subjectDirection, + row.grade, + row.profileDate, + row.notes, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private hasResultData(row: StudentImportRow) { + return [ + row.cultureFinalScore, + row.professionalFinalScore, + row.admissionStatus, + row.admittedCollege, + row.admittedMajor, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private async importArchiveData( + studentId: number, + row: StudentImportRow, + data: StudentWorkbookImport, + ) { + const phone = this.normalizePhone(row.phone); + let imported = 0; + if (this.hasProfileData(row)) { + await this.upsertProfileFromImport(studentId, row); + imported++; + } + if (this.hasResultData(row)) { + await this.upsertResultFromImport(studentId, row); + imported++; + } + if (!phone) return imported; + + const enrollmentByClassName = new Map(); + for (const enrollmentRow of data.enrollments.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + if (!enrollment) continue; + if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); + imported++; + } + for (const examRow of data.examScores.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + imported++; + } + } + for (const learningRow of data.learningRecords.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + imported++; + } + } + return imported; + } + + private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + const entity = + (await this.profileRepo.findOne({ where: { studentId } })) || + this.profileRepo.create({ studentId }); + if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); + if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); + if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); + if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); + if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); + if (row.grade?.trim()) entity.grade = row.grade.trim(); + if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); + if (row.notes?.trim()) entity.notes = row.notes.trim(); + await this.profileRepo.save(entity); + } + + private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + const entity = + (await this.resultRepo.findOne({ where: { studentId } })) || + this.resultRepo.create({ studentId }); + if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; + if (row.professionalFinalScore !== undefined) + entity.professionalFinalScore = row.professionalFinalScore; + if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); + if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); + if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); + await this.resultRepo.save(entity); + } + + private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + if (!row.courseCategory?.trim() || !row.classType?.trim()) { + return null; + } + const existing = await this.enrollmentRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ) || this.enrollmentRepo.create({ studentId }); + entity.courseCategory = row.courseCategory.trim(); + entity.classType = row.classType.trim(); + if (row.className?.trim()) entity.className = row.className.trim(); + if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); + if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); + if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); + if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); + if (row.status?.trim()) entity.status = row.status.trim(); + else if (!entity.status) entity.status = 'active'; + return this.enrollmentRepo.save(entity); + } + + private async upsertExamScoreFromImport( + studentId: number, + row: ExamScoreImportRow, + enrollmentByClassName: Map, + ) { + if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; + const existing = await this.examScoreRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ) || this.examScoreRepo.create({ studentId }); + entity.examType = row.examType.trim(); + entity.subject = row.subject.trim(); + entity.score = row.score; + if (row.examName?.trim()) entity.examName = row.examName.trim(); + if (row.classAvg !== undefined) entity.classAvg = row.classAvg; + if (row.rank !== undefined) entity.rank = row.rank; + if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.enrollmentName?.trim()) { + const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); + if (enrollment) entity.enrollmentId = enrollment.id; + } + if (!entity.status) entity.status = 'active'; + await this.examScoreRepo.save(entity); + return true; + } + + private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; + const existing = await this.learningRecordRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ) || this.learningRecordRepo.create({ studentId }); + entity.recordDate = row.recordDate.trim(); + entity.recordType = row.recordType.trim(); + entity.content = row.content.trim(); + if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); + if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); + if (!entity.status) entity.status = 'active'; + await this.learningRecordRepo.save(entity); + return true; + } +} diff --git a/apps/server/src/students/students.lifecycle.service.ts b/apps/server/src/students/students.lifecycle.service.ts new file mode 100644 index 0000000..5051006 --- /dev/null +++ b/apps/server/src/students/students.lifecycle.service.ts @@ -0,0 +1,235 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { ClassStudent } from '../entities/class-student.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; + +@Injectable() +export class StudentsLifecycleService { + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, + @InjectRepository(AttendanceRecord) + private readonly attendanceRepo: Repository, + @InjectRepository(StudentProfile) private readonly profileRepo: Repository, + @InjectRepository(StudentEnrollment) + private readonly enrollmentRepo: Repository, + @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, + @InjectRepository(LearningRecord) + private readonly learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private readonly resultRepo: Repository, + @InjectRepository(Occupancy) private readonly occupancyRepo: Repository, + @InjectRepository(PersonalExpense) + private readonly personalExpenseRepo: Repository, + @InjectRepository(Bill) private readonly billRepo: Repository, + @InjectRepository(Deposit) private readonly depositRepo: Repository, + @InjectRepository(ArchiveAttachment) + private readonly attachmentRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly dingMappingRepo: Repository, + @InjectRepository(StudentWallet) private readonly walletRepo: Repository, + @InjectRepository(RoomInspectionDetail) + private readonly inspectionDetailRepo: Repository, + ) {} + + private async findOne(id: number) { + const student = await this.repo.findOne({ + where: { id }, + relations: ['occupancies', 'occupancies.room'], + }); + if (!student) throw new NotFoundException('学生不存在'); + return student; + } + + async getArchiveExportMaps(studentIds: number[]) { + if (studentIds.length === 0) { + return { + profiles: new Map(), + results: new Map(), + }; + } + const [profiles, results] = await Promise.all([ + this.profileRepo.find({ where: { studentId: In(studentIds) } }), + this.resultRepo.find({ where: { studentId: In(studentIds) } }), + ]); + return { + profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), + results: new Map(results.map((result) => [result.studentId, result])), + }; + } + + async batchRemove(ids: number[]) { + if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); + const students = await this.repo.find({ where: { id: In(ids) } }); + const skipped: string[] = []; + const targetIds: number[] = []; + for (const s of students) { + if (s.status === 'archived') skipped.push(s.name); + else targetIds.push(s.id); + } + let affected = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + affected = result.affected || 0; + } + const message = + skipped.length > 0 + ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; + return { message, archived: affected, skipped: skipped.length }; + } + + async restore(id: number) { + const student = await this.findOne(id); + if (student.status !== 'archived') { + throw new BadRequestException('该学生未被归档'); + } + await this.repo.update(id, { status: 'active' }); + return { message: '已恢复' }; + } + + private async assertNoStudentReferences(studentId: number) { + const [ + occupancyCount, + personalExpenseCount, + billCount, + depositCount, + classMemberCount, + profileCount, + enrollmentCount, + examScoreCount, + learningRecordCount, + attachmentCount, + resultCount, + attendanceCount, + dingMappingCount, + walletCount, + inspectionDetailCount, + ] = await Promise.all([ + this.occupancyRepo.count({ where: { studentId } }), + this.personalExpenseRepo.count({ where: { studentId } }), + this.billRepo.count({ where: { studentId } }), + this.depositRepo.count({ where: { studentId } }), + this.classStudentRepo.count({ where: { studentId } }), + this.profileRepo.count({ where: { studentId } }), + this.enrollmentRepo.count({ where: { studentId } }), + this.examScoreRepo.count({ where: { studentId } }), + this.learningRecordRepo.count({ where: { studentId } }), + this.attachmentRepo.count({ where: { studentId } }), + this.resultRepo.count({ where: { studentId } }), + this.attendanceRepo.count({ where: { studentId } }), + this.dingMappingRepo.count({ where: { studentId } }), + this.walletRepo.count({ where: { studentId } }), + this.inspectionDetailRepo.count({ where: { studentId } }), + ]); + const refs: Array<[string, number]> = [ + ['入住记录', occupancyCount], + ['个人费用', personalExpenseCount], + ['账单', billCount], + ['押金', depositCount], + ['班级成员', classMemberCount], + ['档案信息', profileCount], + ['报名记录', enrollmentCount], + ['考试成绩', examScoreCount], + ['学习记录', learningRecordCount], + ['档案附件', attachmentCount], + ['录取结果', resultCount], + ['考勤记录', attendanceCount], + ['钉钉映射', dingMappingCount], + ['学生钱包', walletCount], + ['查寝明细', inspectionDetailCount], + ]; + const references = refs.filter(([, count]) => count > 0); + if (references.length > 0) { + const names = references.map(([name]) => name).join('、'); + throw new BadRequestException(`该学生存在关联数据(${names}),无法永久删除`); + } + } + + async purge(id: number) { + const student = await this.findOne(id); + if (student.status !== 'archived') { + throw new BadRequestException('仅已归档学生可以永久删除,请先归档'); + } + await this.assertNoStudentReferences(id); + await this.repo.delete(id); + return { message: '已永久删除学生(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('学生 ID 无效'); + } + const students = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const student of students) { + if (student.status !== 'archived') { + skipped.push(`${student.name}(未归档)`); + continue; + } + try { + await this.assertNoStudentReferences(student.id); + } catch { + skipped.push(`${student.name}(存在关联数据)`); + continue; + } + await this.repo.delete(student.id); + deleted.push(student.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 人;${skipped.length} 人被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 名学生(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('学生 ID 无效'); + } + const students = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); + + const targetIds = students + .filter((student) => student.status === 'archived') + .map((student) => student.id); + const skipped = students.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; + } +} diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index a1576b0..f61c551 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -11,6 +11,14 @@ import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { StudentsService } from './students.service'; import { StudentAccessScopeFactory } from './student-access-scope.factory'; import { StudentsController } from './students.controller'; @@ -29,6 +37,14 @@ import { StudentsController } from './students.controller'; ExamScore, LearningRecord, ResultArchive, + Occupancy, + PersonalExpense, + Bill, + Deposit, + ArchiveAttachment, + StudentDingMapping, + StudentWallet, + RoomInspectionDetail, ]), ], controllers: [StudentsController], diff --git a/apps/server/src/students/students.organization.ts b/apps/server/src/students/students.organization.ts new file mode 100644 index 0000000..457275c --- /dev/null +++ b/apps/server/src/students/students.organization.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Organization } from '../entities/organization.entity'; + +export async function assertActiveOrganization( + organizationRepo: Repository, + id: number, +): Promise { + const organization = await organizationRepo.findOne({ where: { id, status: 'active' } }); + if (!organization) throw new BadRequestException('所属机构不存在或已归档'); +} + +export async function getHostOrganizationId( + organizationRepo: Repository, +): Promise { + const organization = await organizationRepo.findOne({ + where: { isHost: true, status: 'active' }, + }); + if (!organization) throw new BadRequestException('尚未配置本机构'); + return organization.id; +} diff --git a/apps/server/src/students/students.purge.controller.spec.ts b/apps/server/src/students/students.purge.controller.spec.ts new file mode 100644 index 0000000..b35d068 --- /dev/null +++ b/apps/server/src/students/students.purge.controller.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { StudentsController } from './students.controller'; + +describe('StudentsController purge routes', () => { + it('requires student:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.purge)).toEqual([ + 'student:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.batchPurge), + ).toEqual(['student:purge']); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除学生(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new StudentsController( + service as never, + { log } as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '学生管理', action: '永久删除学生', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/students/students.purge.spec.ts b/apps/server/src/students/students.purge.spec.ts new file mode 100644 index 0000000..37907fb --- /dev/null +++ b/apps/server/src/students/students.purge.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; +import { StudentsService } from './students.service'; + +const student = { id: 1, name: '张三', status: 'archived' }; + +const createService = (overrides?: { + student?: Record; + counts?: Record; +}) => { + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const repo = { + findOne: jest.fn().mockResolvedValue(overrides?.student ?? student), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([overrides?.student ?? student]), + }; + const occupancyCount = countFor('occupancy'); + const service = new StudentsService( + repo as never, + { count: countFor('classStudent') } as never, + {} as never, + { count: countFor('attendance') } as never, + {} as never, + {} as never, + { count: countFor('profile') } as never, + { count: countFor('enrollment') } as never, + { count: countFor('examScore') } as never, + { count: countFor('learningRecord') } as never, + { count: countFor('result') } as never, + { count: occupancyCount } as never, + { count: countFor('personalExpense') } as never, + { count: countFor('bill') } as never, + { count: countFor('deposit') } as never, + { count: countFor('attachment') } as never, + { count: countFor('dingMapping') } as never, + { count: countFor('wallet') } as never, + { count: countFor('inspectionDetail') } as never, + ); + return { service, repo, occupancyCount }; +}; + +describe('StudentsService.purge', () => { + it('rejects students that are not archived', async () => { + const { service, repo } = createService({ student: { id: 1, name: '张三', status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档学生可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects students with any reference', async () => { + const { service, repo } = createService({ counts: { occupancy: 2 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该学生存在关联数据(入住记录),无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived student with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除学生(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge returns deleted and skipped counts', async () => { + const { service, repo, occupancyCount } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, name: '甲', status: 'archived' }, + { id: 2, name: '乙', status: 'archived' }, + { id: 3, name: '丙', status: 'active' }, + ]); + occupancyCount.mockResolvedValueOnce(1).mockResolvedValue(0); + const result = await service.batchPurge([1, 2, 3]); + expect(result).toMatchObject({ deleted: 1, skipped: 2 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index f6a7593..0f8cfb9 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,29 +1,37 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm'; +import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; -import type { - ExamScoreImportRow, - LearningRecordImportRow, - StudentEnrollmentImportRow, - StudentImportRow, - StudentWorkbookImport, -} from './student-import'; -import type { StudentAccessScope } from './student-access-scope'; +import { assertActiveOrganization } from './students.organization'; +import { StudentsImportService } from './students.import.service'; +import { StudentsLifecycleService } from './students.lifecycle.service'; +import { StudentsAgentService } from './students.agent.service'; @Injectable() export class StudentsService { + private importService?: StudentsImportService; + private lifecycleService?: StudentsLifecycleService; + private agentService?: StudentsAgentService; + constructor( @InjectRepository(Student) private repo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @@ -36,8 +44,63 @@ export class StudentsService { @InjectRepository(ExamScore) private examScoreRepo: Repository, @InjectRepository(LearningRecord) private learningRecordRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, + @InjectRepository(Occupancy) private occupancyRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpenseRepo: Repository, + @InjectRepository(Bill) private billRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository, + @InjectRepository(StudentDingMapping) private dingMappingRepo: Repository, + @InjectRepository(StudentWallet) private walletRepo: Repository, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, ) {} + private get imports(): StudentsImportService { + if (!this.importService) { + this.importService = new StudentsImportService( + this.repo, + this.profileRepo, + this.enrollmentRepo, + this.examScoreRepo, + this.learningRecordRepo, + this.resultRepo, + this.organizationRepo, + ); + } + return this.importService; + } + + private get lifecycle(): StudentsLifecycleService { + if (!this.lifecycleService) { + this.lifecycleService = new StudentsLifecycleService( + this.repo, + this.classStudentRepo, + this.attendanceRepo, + this.profileRepo, + this.enrollmentRepo, + this.examScoreRepo, + this.learningRecordRepo, + this.resultRepo, + this.occupancyRepo, + this.personalExpenseRepo, + this.billRepo, + this.depositRepo, + this.attachmentRepo, + this.dingMappingRepo, + this.walletRepo, + this.inspectionDetailRepo, + ); + } + return this.lifecycleService; + } + + private get agents(): StudentsAgentService { + if (!this.agentService) { + this.agentService = new StudentsAgentService(this.repo, this.classStudentRepo); + } + return this.agentService; + } + async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); @@ -52,21 +115,8 @@ export class StudentsService { }); } - async getArchiveExportMaps(studentIds: number[]) { - if (studentIds.length === 0) { - return { - profiles: new Map(), - results: new Map(), - }; - } - const [profiles, results] = await Promise.all([ - this.profileRepo.find({ where: { studentId: In(studentIds) } }), - this.resultRepo.find({ where: { studentId: In(studentIds) } }), - ]); - return { - profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), - results: new Map(results.map((result) => [result.studentId, result])), - }; + async getArchiveExportMaps(...args: Parameters) { + return this.lifecycle.getArchiveExportMaps(...args); } async findAll( @@ -164,13 +214,13 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - await this.assertActiveOrganization(dto.organizationId); + await assertActiveOrganization(this.organizationRepo, dto.organizationId); return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { await this.findOne(id); - if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId); + if (dto.organizationId) await assertActiveOrganization(this.organizationRepo, dto.organizationId); await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } @@ -184,345 +234,32 @@ export class StudentsService { return { message: '已归档(数据已保留,可随时恢复)' }; } - async batchRemove(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); - const students = await this.repo.find({ where: { id: In(ids) } }); - const skipped: string[] = []; - const targetIds: number[] = []; - for (const s of students) { - if (s.status === 'archived') skipped.push(s.name); - else targetIds.push(s.id); - } - let affected = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - affected = result.affected || 0; - } - const message = - skipped.length > 0 - ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` - : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; - return { message, archived: affected, skipped: skipped.length }; + async batchRemove(...args: Parameters) { + return this.lifecycle.batchRemove(...args); } - async restore(id: number) { - const student = await this.findOne(id); - if (student.status !== 'archived') { - throw new BadRequestException('该学生未被归档'); - } - await this.repo.update(id, { status: 'active' }); - return { message: '已恢复' }; + async restore(...args: Parameters) { + return this.lifecycle.restore(...args); } - async batchRestore(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('学生 ID 无效'); - } - const students = await this.repo.find({ where: { id: In(uniqueIds) } }); - if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); - - const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id); - const skipped = students.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; + async purge(...args: Parameters) { + return this.lifecycle.purge(...args); } - async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { - const data = this.normalizeImportData(importData); - let imported = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; - } - const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); - if (exists) { - skipped++; - continue; - } - const student = await this.repo.save( - this.repo.create({ - name: row.name.trim(), - studentNo: row.studentNo?.trim() || undefined, - phone: row.phone?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender || undefined, - ethnicity: row.ethnicity || undefined, - emergencyContact: row.emergencyContact || undefined, - emergencyPhone: row.emergencyPhone || undefined, - supervisor: row.supervisor || undefined, - organizationId: row.organizationId || (await this.getHostOrganizationId()), - }), - ); - archiveImported += await this.importArchiveData(student.id, row, data); - imported++; - } - return { - message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, - imported, - archiveImported, - skipped, - }; + async batchPurge(...args: Parameters) { + return this.lifecycle.batchPurge(...args); } - async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { - const data = this.normalizeImportData(importData); - let matched = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - // Match by phone first, then idNumber - let student = row.phone?.trim() - ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) - : null; - if (!student && row.idNumber?.trim()) { - student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); - } - if (!student) { - skipped++; - continue; - } - // Update matched student with non-empty imported fields - const updates: Partial< - Pick< - Student, - | 'name' - | 'studentNo' - | 'phone' - | 'idNumber' - | 'gender' - | 'ethnicity' - | 'emergencyContact' - | 'emergencyPhone' - | 'supervisor' - | 'organizationId' - > - > = {}; - if (row.name?.trim()) updates.name = row.name.trim(); - if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (row.phone?.trim()) updates.phone = row.phone.trim(); - if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (row.gender) updates.gender = row.gender; - if (row.ethnicity) updates.ethnicity = row.ethnicity; - if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; - if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; - if (row.supervisor) updates.supervisor = row.supervisor; - if (row.organizationId) updates.organizationId = row.organizationId; - await this.repo.update(student.id, updates); - archiveImported += await this.importArchiveData(student.id, row, data); - matched++; - } - return { - message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, - matched, - archiveImported, - skipped, - }; + async batchRestore(...args: Parameters) { + return this.lifecycle.batchRestore(...args); } - private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { - if (Array.isArray(importData)) { - return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; - } - return importData; + async batchImport(...args: Parameters) { + return this.imports.batchImport(...args); } - private normalizePhone(phone?: string) { - return phone?.trim() || ''; - } - - private sameValue(left?: string | number | null, right?: string | number | null) { - return String(left ?? '').trim() === String(right ?? '').trim(); - } - - private hasProfileData(row: StudentImportRow) { - return [ - row.targetCollege, - row.targetMajor, - row.collegeSchool, - row.collegeMajor, - row.subjectDirection, - row.grade, - row.profileDate, - row.notes, - ].some((value) => value !== undefined && String(value).trim() !== ''); - } - - private hasResultData(row: StudentImportRow) { - return [ - row.cultureFinalScore, - row.professionalFinalScore, - row.admissionStatus, - row.admittedCollege, - row.admittedMajor, - ].some((value) => value !== undefined && String(value).trim() !== ''); - } - - private async importArchiveData( - studentId: number, - row: StudentImportRow, - data: StudentWorkbookImport, - ) { - const phone = this.normalizePhone(row.phone); - let imported = 0; - if (this.hasProfileData(row)) { - await this.upsertProfileFromImport(studentId, row); - imported++; - } - if (this.hasResultData(row)) { - await this.upsertResultFromImport(studentId, row); - imported++; - } - if (!phone) return imported; - - const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { - const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); - if (!enrollment) continue; - if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); - imported++; - } - for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { - if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { - imported++; - } - } - for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { - if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { - imported++; - } - } - return imported; - } - - private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); - if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); - if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); - if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); - if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); - if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); - if (row.grade?.trim()) entity.grade = row.grade.trim(); - if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); - if (row.notes?.trim()) entity.notes = row.notes.trim(); - await this.profileRepo.save(entity); - } - - private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); - if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; - if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); - if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); - if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); - await this.resultRepo.save(entity); - } - - private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { - if (!row.courseCategory?.trim() || !row.classType?.trim()) { - return null; - } - const existing = await this.enrollmentRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.courseCategory, row.courseCategory) && - this.sameValue(item.classType, row.classType) && - this.sameValue(item.className, row.className) && - this.sameValue(item.startDate, row.startDate), - ) || this.enrollmentRepo.create({ studentId }); - entity.courseCategory = row.courseCategory.trim(); - entity.classType = row.classType.trim(); - if (row.className?.trim()) entity.className = row.className.trim(); - if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); - if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); - if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); - if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); - if (row.status?.trim()) entity.status = row.status.trim(); - else if (!entity.status) entity.status = 'active'; - return this.enrollmentRepo.save(entity); - } - - private async upsertExamScoreFromImport( - studentId: number, - row: ExamScoreImportRow, - enrollmentByClassName: Map, - ) { - if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; - const existing = await this.examScoreRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.examType, row.examType) && - this.sameValue(item.examName, row.examName) && - this.sameValue(item.subject, row.subject) && - this.sameValue(item.examDate, row.examDate), - ) || this.examScoreRepo.create({ studentId }); - entity.examType = row.examType.trim(); - entity.subject = row.subject.trim(); - entity.score = row.score; - if (row.examName?.trim()) entity.examName = row.examName.trim(); - if (row.classAvg !== undefined) entity.classAvg = row.classAvg; - if (row.rank !== undefined) entity.rank = row.rank; - if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); - if (row.enrollmentName?.trim()) { - const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); - if (enrollment) entity.enrollmentId = enrollment.id; - } - if (!entity.status) entity.status = 'active'; - await this.examScoreRepo.save(entity); - return true; - } - - private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { - if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; - const existing = await this.learningRecordRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.recordDate, row.recordDate) && - this.sameValue(item.recordType, row.recordType) && - this.sameValue(item.content, row.content), - ) || this.learningRecordRepo.create({ studentId }); - entity.recordDate = row.recordDate.trim(); - entity.recordType = row.recordType.trim(); - entity.content = row.content.trim(); - if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); - if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); - if (!entity.status) entity.status = 'active'; - await this.learningRecordRepo.save(entity); - return true; - } - - private async assertActiveOrganization(id: number) { - const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); - if (!organization) throw new BadRequestException('所属机构不存在或已归档'); - } - - private async getHostOrganizationId() { - const organization = await this.organizationRepo.findOne({ - where: { isHost: true, status: 'active' }, - }); - if (!organization) throw new BadRequestException('尚未配置本机构'); - return organization.id; + async matchImport(...args: Parameters) { + return this.imports.matchImport(...args); } async compareClasses(studentId: number) { @@ -581,228 +318,15 @@ export class StudentsService { return { student, enrollments: comparison }; } - // ------------------------------------------------------------------------- - // Agent-safe query APIs — SQL-level scope + field whitelist - // ------------------------------------------------------------------------- - - /** - * Whitelisted output type for agent student searches. - * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone. - */ - private static readonly AGENT_STUDENT_SELECT = [ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'organization.name', - ] as const; - - /** - * Search students with SQL-enforced scope, field whitelist, and limit. - * - * @param scope — data-range discriminator (manageAll or teacher). - * @param query — optional keyword, classId, organizationId, limit. - * @returns formatted whitelist-only results with classIds. - */ async agentSearchStudents( - scope: StudentAccessScope, - query?: { - keyword?: string; - classId?: number; - organizationId?: number; - limit?: number; - }, - ): Promise< - { - id: number; - name: string; - studentNo: string; - gender: string; - status: string; - organizationId: number; - organizationName: string; - classIds: number[]; - }[] - > { - const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); - - const qb = this.repo - .createQueryBuilder('student') - .distinct(true) - .select([ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'student.createdAt', - 'organization.name', - ]) - .leftJoin('student.organization', 'organization'); - - // ---- Scope enforcement ---- - this.applyStudentScope(qb, scope, query?.classId); - - // ---- Filters ---- - if (query?.keyword) { - qb.andWhere( - '(student.name LIKE :keyword OR student.student_no LIKE :keyword)', - { keyword: `%${query.keyword}%` }, - ); - } - if (query?.organizationId) { - qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId }); - } - - qb.orderBy('student.createdAt', 'DESC').take(limit); - - const rows: Record[] = await qb.getRawMany(); - if (rows.length === 0) return []; - - // Second bounded query: classIds only for the returned student ids. - // For teacher scope, the class filter MUST be re-applied so the - // teacher only sees classIds they are assigned to. - const studentIds = rows.map((r) => r.student_id as number); - const csQb = this.classStudentRepo - .createQueryBuilder('cs') - .select(['cs.studentId', 'cs.classId']) - .where('cs.student_id IN (:...ids)', { ids: studentIds }) - .andWhere('cs.status = :status', { status: 'active' }); - - if (scope.type === 'teacher') { - csQb.andWhere( - 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', - { scopeTeacherUserId: scope.userId }, - ); - } - - const classRows = await csQb.getRawMany(); - - const classMap = new Map(); - for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { - const sid = cr.cs_student_id; - if (!classMap.has(sid)) classMap.set(sid, []); - classMap.get(sid)!.push(cr.cs_class_id); - } - - return rows.map((r) => ({ - id: r.student_id as number, - name: r.student_name as string, - studentNo: (r.student_student_no as string) ?? '', - gender: (r.student_gender as string) ?? '', - status: r.student_status as string, - organizationId: r.student_organization_id as number, - organizationName: (r.organization_name as string) ?? '', - classIds: classMap.get(r.student_id as number) ?? [], - })); + ...args: Parameters + ) { + return this.agents.agentSearchStudents(...args); } - /** - * Get single student basic info with SQL-enforced scope + whitelist. - * Returns `null` for students out of scope or non-existent (no leak). - */ async agentGetStudentBasic( - scope: StudentAccessScope, - studentId: number, - ): Promise<{ - id: number; - name: string; - studentNo: string; - gender: string; - status: string; - organizationId: number; - organizationName: string; - classIds: number[]; - } | null> { - const qb = this.repo - .createQueryBuilder('student') - .select([ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'organization.name', - ]) - .leftJoin('student.organization', 'organization') - .where('student.id = :studentId', { studentId }); - - this.applyStudentScope(qb, scope); - - const row = await qb.getRawOne(); - if (!row) return null; - - // For teacher scope, re-apply class filter so teacher only sees - // classIds they are assigned to (not ALL active classIds of the student). - const csQb = this.classStudentRepo - .createQueryBuilder('cs') - .select(['cs.classId']) - .where('cs.student_id = :studentId', { studentId }) - .andWhere('cs.status = :status', { status: 'active' }); - - if (scope.type === 'teacher') { - csQb.andWhere( - 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', - { scopeTeacherUserId: scope.userId }, - ); - } - - const classRows = await csQb.getRawMany(); - - return { - id: row.student_id as number, - name: row.student_name as string, - studentNo: (row.student_student_no as string) ?? '', - gender: (row.student_gender as string) ?? '', - status: row.student_status as string, - organizationId: row.student_organization_id as number, - organizationName: (row.organization_name as string) ?? '', - classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), - }; - } - - /** - * Apply data-range scope to a student QueryBuilder. - * - * - `manageAll`: no restriction. - * - `teacher`: INNER JOIN ClassStudent → active students in the - * teacher's assigned classes (via ClassTeacher). - * - When `classId` is provided, it is ANDed with the scope - * (intersection) — the model cannot widen access. - */ - private applyStudentScope( - qb: ReturnType, - scope: StudentAccessScope, - classId?: number, - ): void { - if (scope.type === 'manageAll') { - if (classId != null) { - qb.innerJoin( - 'class_student', - 'cs_scope', - 'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus', - { scopeClassId: classId, scopeCsStatus: 'active' }, - ); - } - return; - } - - // Teacher scope: active students in teacher's assigned classes - const teacherClause = - 'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' + - '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)'; - - qb.innerJoin('class_student', 'cs_scope', teacherClause, { - scopeTeacherUserId: scope.userId, - scopeCsStatus: 'active', - }); - - if (classId != null) { - qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId }); - } + ...args: Parameters + ) { + return this.agents.agentGetStudentBasic(...args); } } diff --git a/apps/server/src/sync/jinshuju-rules.ts b/apps/server/src/sync/jinshuju-rules.ts new file mode 100644 index 0000000..6248b3f --- /dev/null +++ b/apps/server/src/sync/jinshuju-rules.ts @@ -0,0 +1,46 @@ +import { ConflictException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { JinshujuMatchRule, type JinshujuFieldMapping } from '../entities/jinshuju-match-rule.entity'; + +export async function getMatchRule( + repo: Repository, + id: number, + formToken: string, +): Promise { + const rule = await repo.findOne({ where: { id } }); + if (!rule) throw new ConflictException('规则不存在'); + if (rule.formToken !== formToken) { + throw new ConflictException('匹配规则不属于当前表单'); + } + return rule; +} + +export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapping): void { + if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空'); + if (!mappings.name) throw new ConflictException('匹配规则必须映射姓名字段'); + const allowedStudentFields = new Set([ + 'name', + 'studentNo', + 'phone', + 'idNumber', + 'gender', + 'ethnicity', + 'emergencyContact', + 'emergencyPhone', + ]); + for (const [studentField, fieldKey] of Object.entries(mappings)) { + if (!allowedStudentFields.has(studentField)) { + throw new ConflictException(`不允许映射学生字段:${studentField}`); + } + if (fieldKey && !/^field_\d+$/.test(fieldKey)) { + throw new ConflictException(`无效的金数据字段:${fieldKey}`); + } + } +} + +/** Extract value from a Jinshuju entry by field mapping. */ +export function extractField(entry: Record, fieldKey: string | undefined): string { + if (!fieldKey) return ''; + const val = entry[fieldKey]; + return typeof val === 'string' ? val.trim() : ''; +} diff --git a/apps/server/src/sync/schedule-sync.helpers.ts b/apps/server/src/sync/schedule-sync.helpers.ts new file mode 100644 index 0000000..5728151 --- /dev/null +++ b/apps/server/src/sync/schedule-sync.helpers.ts @@ -0,0 +1,185 @@ +import { Repository, In } from 'typeorm'; +import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; +import type { DingTalkScheduleItem } from '../integration/dingtalk.service'; + +export interface DailySchedulePeriod { + startTime: string; + endTime: string; + scheduleId: number; +} + +export interface DailySchedulePlan { + classId: number; + date: string; + shiftKey: string; + periods: DailySchedulePeriod[]; +} + +/** 单次排班同步的结果 */ +export interface ScheduleSyncResult { + /** 参与同步的排课记录数 */ + scheduleCount: number; + /** 创建/复用的班次数 */ + shiftCount: number; + /** 创建/复用的考勤组数 */ + groupCount: number; + /** 实际写入钉钉的排班条数 */ + syncedItems: number; + /** 因无学生或无钉钉映射而跳过的排课数 */ + skippedNoMapping: number; + /** 写入失败的排班批次数 */ + failedBatchCount: number; + /** 写入失败的排班条数 */ + failedItems: number; + /** 失败批次错误详情 */ + errors: string[]; + /** 按班级分组的详情 */ + groups: Array<{ + className: string; + groupId: number; + itemCount: number; + }>; +} + +export async function buildClassDingUserMap( + classStudentRepo: Repository, + mappingRepo: Repository, + classIds: number[], +): Promise> { + const result = new Map(); + if (classIds.length === 0) return result; + + // 班级 → 活跃学生 + const links = await classStudentRepo.find({ + where: { classId: In(classIds), status: 'active' }, + }); + if (links.length === 0) return result; + + // 学生 → 钉钉 userId + const studentIds = [...new Set(links.map((l) => l.studentId))]; + const mappings = await mappingRepo.find({ + where: { studentId: In(studentIds) }, + }); + const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); + + for (const link of links) { + const dingId = studentToDing.get(link.studentId); + if (!dingId) continue; + if (!result.has(link.classId)) result.set(link.classId, []); + const arr = result.get(link.classId)!; + if (!arr.includes(dingId)) arr.push(dingId); + } + return result; +} + +export async function loadClassNames( + classRepo: Repository, + classIds: number[], +): Promise> { + const map = new Map(); + if (classIds.length === 0) return map; + const classes = await classRepo.find({ where: { id: In(classIds) } }); + for (const c of classes) map.set(c.id, c.name); + return map; +} + +/** + * 把本地排课转换为“班级 + 日期”的日排班计划。 + * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 + */ +export function buildDailySchedulePlans( + schedules: ClassSchedule[], + syncFrom: string, + syncTo: string, +): DailySchedulePlan[] { + const periodMapByClassDate = new Map>(); + const fromDate = new Date(`${syncFrom}T00:00:00.000Z`); + const toDate = new Date(`${syncTo}T00:00:00.000Z`); + + for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { + const dateStr = date.toISOString().slice(0, 10); + const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); + + for (const schedule of schedules) { + if (schedule.classId == null || schedule.weekDay !== weekDay) continue; + if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue; + + const classDateKey = `${schedule.classId}|${dateStr}`; + if (!periodMapByClassDate.has(classDateKey)) { + periodMapByClassDate.set(classDateKey, new Map()); + } + const periods = periodMapByClassDate.get(classDateKey)!; + const periodKey = `${schedule.startTime}-${schedule.endTime}`; + const existing = periods.get(periodKey); + if (!existing || schedule.id < existing.scheduleId) { + periods.set(periodKey, { + startTime: schedule.startTime, + endTime: schedule.endTime, + scheduleId: schedule.id, + }); + } + } + } + + const plans: DailySchedulePlan[] = []; + for (const [classDateKey, periodMap] of periodMapByClassDate) { + const separator = classDateKey.indexOf('|'); + const classId = Number(classDateKey.slice(0, separator)); + const date = classDateKey.slice(separator + 1); + const periods = [...periodMap.values()].sort( + (left, right) => + left.startTime.localeCompare(right.startTime) || + left.endTime.localeCompare(right.endTime) || + left.scheduleId - right.scheduleId, + ); + const periodSignature = periods + .map((period) => `${period.startTime}-${period.endTime}`) + .join('+'); + plans.push({ + classId, + date, + shiftKey: `${classId}|${periodSignature}`, + periods, + }); + } + + return plans.sort( + (left, right) => left.date.localeCompare(right.date) || left.classId - right.classId, + ); +} + +/** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */ +export function expandDailySchedulePlans( + plans: DailySchedulePlan[], + dingUserIds: string[], + planToShiftId: Map, +): DingTalkScheduleItem[] { + const items: DingTalkScheduleItem[] = []; + for (const plan of plans) { + const shiftId = planToShiftId.get(plan.shiftKey); + if (!shiftId) continue; + const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); + for (const userid of dingUserIds) { + items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); + } + } + return items; +} + +export function toMinutes(time: string): number { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; +} + +export function minutesBetween(startTime: string, endTime: string): number { + const start = toMinutes(startTime); + let end = toMinutes(endTime); + if (end <= start) end += 24 * 60; + return end - start; +} + +export function addDays(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 00acf8b..a3c4d59 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -1,69 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { Repository } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; -import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; +import { DingTalkService } from '../integration/dingtalk.service'; +import { + buildClassDingUserMap, + loadClassNames, + buildDailySchedulePlans, + expandDailySchedulePlans, + toMinutes, + minutesBetween, + addDays, + type DailySchedulePeriod, + type DailySchedulePlan, + type ScheduleSyncResult, +} from './schedule-sync.helpers'; -interface DailySchedulePeriod { - startTime: string; - endTime: string; - scheduleId: number; -} - -interface DailySchedulePlan { - classId: number; - date: string; - shiftKey: string; - periods: DailySchedulePeriod[]; -} - -/** 单次排班同步的结果 */ -export interface ScheduleSyncResult { - /** 参与同步的排课记录数 */ - scheduleCount: number; - /** 创建/复用的班次数 */ - shiftCount: number; - /** 创建/复用的考勤组数 */ - groupCount: number; - /** 实际写入钉钉的排班条数 */ - syncedItems: number; - /** 因无学生或无钉钉映射而跳过的排课数 */ - skippedNoMapping: number; - /** 写入失败的排班批次数 */ - failedBatchCount: number; - /** 写入失败的排班条数 */ - failedItems: number; - /** 失败批次错误详情 */ - errors: string[]; - /** 按班级分组的详情 */ - groups: Array<{ - className: string; - groupId: number; - itemCount: number; - }>; -} - -/** - * 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。 - * - * ## 同步流程(按班级学生) - * 1. 查询活跃排课,按 classId 分组 - * 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId - * 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次) - * 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次) - * 5. 将排课展开为每个学生的每日排班,批量写入钉钉 - * - * ## 残余风险:同步窗口内已不存在的旧排班无法清理 - * 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和 - * `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口 - * 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。 - * 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机; - * 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。 - * - * ## API 调用优化 - * - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。 - * - 排班写入按考勤组分批(钉钉单次最多 200 条)。 - */ @Injectable() export class ScheduleSyncService { private readonly logger = new Logger(ScheduleSyncService.name); @@ -95,7 +47,7 @@ export class ScheduleSyncService { ): Promise { const startDate = dateFrom || new Date().toISOString().slice(0, 10); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; - const endDate = this.addDays(startDate, normalizedDays - 1); + const endDate = addDays(startDate, normalizedDays - 1); const empty: ScheduleSyncResult = { scheduleCount: 0, @@ -121,13 +73,13 @@ export class ScheduleSyncService { // ── Step 2: 班级 → 学生钉钉ID 映射 ── const classIds = [...new Set(schedules.map((s) => s.classId as number))]; - const classDingUsers = await this.buildClassDingUserMap(classIds); - const classNameMap = await this.loadClassNames(classIds); + const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); + const classNameMap = await loadClassNames(this.classRepo, classIds); // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 - const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate); + const dailyPlans = buildDailySchedulePlans(schedules, startDate, endDate); const uniqueShifts = new Map< string, { className: string; periods: DailySchedulePeriod[] } @@ -171,7 +123,7 @@ export class ScheduleSyncService { }, { check_type: 'OffDuty' as const, - across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0, + across: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 0, check_time: `1970-01-01 ${period.endTime}:00`, free_check: false, }, @@ -181,7 +133,7 @@ export class ScheduleSyncService { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: Math.max( - ...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)), + ...periods.map((period) => minutesBetween(period.startTime, period.endTime)), ), }, }; @@ -242,7 +194,7 @@ export class ScheduleSyncService { } // 先展开排班以计算受影响条数 - const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); + const items = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); if (items.length === 0) { this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); @@ -333,143 +285,6 @@ export class ScheduleSyncService { * 构建 classId → 学生钉钉 userId 列表。 * 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。 */ - private async buildClassDingUserMap(classIds: number[]): Promise> { - const result = new Map(); - if (classIds.length === 0) return result; - - // 班级 → 活跃学生 - const links = await this.classStudentRepo.find({ - where: { classId: In(classIds), status: 'active' }, - }); - if (links.length === 0) return result; - - // 学生 → 钉钉 userId - const studentIds = [...new Set(links.map((l) => l.studentId))]; - const mappings = await this.mappingRepo.find({ - where: { studentId: In(studentIds) }, - }); - const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); - - for (const link of links) { - const dingId = studentToDing.get(link.studentId); - if (!dingId) continue; - if (!result.has(link.classId)) result.set(link.classId, []); - const arr = result.get(link.classId)!; - if (!arr.includes(dingId)) arr.push(dingId); - } - return result; - } - - private async loadClassNames(classIds: number[]): Promise> { - const map = new Map(); - if (classIds.length === 0) return map; - const classes = await this.classRepo.find({ where: { id: In(classIds) } }); - for (const c of classes) map.set(c.id, c.name); - return map; - } - - /** - * 把本地排课转换为“班级 + 日期”的日排班计划。 - * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 - */ - private buildDailySchedulePlans( - schedules: ClassSchedule[], - syncFrom: string, - syncTo: string, - ): DailySchedulePlan[] { - const periodMapByClassDate = new Map>(); - const fromDate = new Date(`${syncFrom}T00:00:00.000Z`); - const toDate = new Date(`${syncTo}T00:00:00.000Z`); - - for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { - const dateStr = date.toISOString().slice(0, 10); - const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); - - for (const schedule of schedules) { - if (schedule.classId == null || schedule.weekDay !== weekDay) continue; - if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue; - - const classDateKey = `${schedule.classId}|${dateStr}`; - if (!periodMapByClassDate.has(classDateKey)) { - periodMapByClassDate.set(classDateKey, new Map()); - } - const periods = periodMapByClassDate.get(classDateKey)!; - const periodKey = `${schedule.startTime}-${schedule.endTime}`; - const existing = periods.get(periodKey); - if (!existing || schedule.id < existing.scheduleId) { - periods.set(periodKey, { - startTime: schedule.startTime, - endTime: schedule.endTime, - scheduleId: schedule.id, - }); - } - } - } - - const plans: DailySchedulePlan[] = []; - for (const [classDateKey, periodMap] of periodMapByClassDate) { - const separator = classDateKey.indexOf('|'); - const classId = Number(classDateKey.slice(0, separator)); - const date = classDateKey.slice(separator + 1); - const periods = [...periodMap.values()].sort( - (left, right) => - left.startTime.localeCompare(right.startTime) || - left.endTime.localeCompare(right.endTime) || - left.scheduleId - right.scheduleId, - ); - const periodSignature = periods - .map((period) => `${period.startTime}-${period.endTime}`) - .join('+'); - plans.push({ - classId, - date, - shiftKey: `${classId}|${periodSignature}`, - periods, - }); - } - - return plans.sort( - (left, right) => left.date.localeCompare(right.date) || left.classId - right.classId, - ); - } - - /** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */ - private expandDailySchedulePlans( - plans: DailySchedulePlan[], - dingUserIds: string[], - planToShiftId: Map, - ): DingTalkScheduleItem[] { - const items: DingTalkScheduleItem[] = []; - for (const plan of plans) { - const shiftId = planToShiftId.get(plan.shiftKey); - if (!shiftId) continue; - const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); - for (const userid of dingUserIds) { - items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); - } - } - return items; - } - - private toMinutes(time: string): number { - const [hour, minute] = time.split(':').map(Number); - return hour * 60 + minute; - } - - private minutesBetween(startTime: string, endTime: string): number { - const start = this.toMinutes(startTime); - let end = this.toMinutes(endTime); - if (end <= start) end += 24 * 60; - return end - start; - } - - private addDays(dateStr: string, days: number): string { - const d = new Date(`${dateStr}T00:00:00.000Z`); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); - } - - /** 获取排班同步状态:活跃排课数、有钉钉映射学生的班级数 */ async getStatus(_targetDate: string): Promise<{ activeSchedules: number; mappedClasses: number; @@ -478,7 +293,7 @@ export class ScheduleSyncService { const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } }); const schedules = allSchedules.filter((s) => s.classId != null); const classIds = [...new Set(schedules.map((s) => s.classId as number))]; - const classDingUsers = await this.buildClassDingUserMap(classIds); + const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length; return { diff --git a/apps/server/src/sync/sync-runner.ts b/apps/server/src/sync/sync-runner.ts new file mode 100644 index 0000000..38893d2 --- /dev/null +++ b/apps/server/src/sync/sync-runner.ts @@ -0,0 +1,112 @@ +import { ConflictException, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; +import { Repository } from 'typeorm'; +import { SyncLog, SyncState } from '../entities'; +import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity'; + +const LEASE_MS = 30 * 60 * 1000; + +@Injectable() +export class SyncRunner { + private readonly logger = new Logger('SyncRunner'); + + constructor( + @InjectRepository(SyncState) + private readonly syncStateRepo: Repository, + @InjectRepository(SyncLog) + private readonly syncLogRepo: Repository, + ) {} + + async run( + platform: SyncPlatform, + operation: (lastSyncAt: Date | null) => Promise<{ + recordsCount: number; + status: Extract; + message?: string; + }>, + ): Promise { + const runId = await this.acquireLease(platform); + let log: SyncLog | undefined; + try { + const lastSyncAt = await this.getLastSyncAt(platform); + log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running'); + const result = await operation(lastSyncAt); + await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() }); + await this.finishSyncLog(log, result.status, result.recordsCount, result.message); + return log; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (log) await this.finishSyncLog(log, 'failed', 0, message); + this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); + throw error; + } finally { + await this.releaseLease(platform, runId); + } + } + + private async acquireLease(platform: SyncPlatform): Promise { + await this.syncStateRepo + .createQueryBuilder() + .insert() + .values({ platform, lastSyncAt: null, runId: null, runningSince: null }) + .orIgnore() + .execute(); + + const runId = randomUUID(); + const result = await this.syncStateRepo + .createQueryBuilder() + .update() + .set({ runId, runningSince: new Date() }) + .where('platform = :platform', { platform }) + .andWhere('(running_since IS NULL OR running_since < :staleBefore)', { + staleBefore: new Date(Date.now() - LEASE_MS), + }) + .execute(); + if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`); + return runId; + } + + private async releaseLease(platform: SyncPlatform, runId: string): Promise { + await this.syncStateRepo + .createQueryBuilder() + .update() + .set({ runId: null, runningSince: null }) + .where('platform = :platform AND run_id = :runId', { platform, runId }) + .execute(); + } + + private async getLastSyncAt(platform: SyncPlatform): Promise { + const state = await this.syncStateRepo.findOne({ where: { platform } }); + return state?.lastSyncAt ?? null; + } + + private async createSyncLog( + platform: SyncPlatform, + syncType: SyncType, + status: SyncStatus, + ): Promise { + return this.syncLogRepo.save( + this.syncLogRepo.create({ + platform, + syncType, + status, + recordsCount: 0, + startedAt: new Date(), + }), + ); + } + + private async finishSyncLog( + log: SyncLog, + status: SyncStatus, + recordsCount: number, + errorMessage?: string, + ): Promise { + log.status = status; + log.recordsCount = recordsCount; + log.finishedAt = new Date(); + log.errorMessage = errorMessage ?? null; + await this.syncLogRepo.save(log); + } +} diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index a3148ab..aa83bf3 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -198,9 +198,9 @@ export class SyncController { @RequirePermission('sync:read') async getLogs( @Query('platform') platform?: SyncPlatform, - @Query('limit') limit?: number, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, ) { - return this.syncService.getLogs(platform, limit ? Number(limit) : 50); + return this.syncService.getLogs(platform, limit ?? 50); } // ── 排班同步 ── diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index b21fdab..66f8730 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 实体注册列表声明结构相似 import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { IntegrationModule } from '../integration/integration.module'; @@ -16,6 +17,7 @@ import { } from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; +import { SyncRunner } from './sync-runner'; import { ScheduleSyncService } from './schedule-sync.service'; @Module({ @@ -36,7 +38,8 @@ import { ScheduleSyncService } from './schedule-sync.service'; AttendanceModule, ], controllers: [SyncController], - providers: [SyncService, ScheduleSyncService], + providers: [SyncService, ScheduleSyncService, SyncRunner, + ], exports: [SyncService], }) export class SyncModule {} diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index 8780816..e497b4a 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -1,6 +1,7 @@ import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { Student, SyncLog } from '../entities'; import { SyncService } from './sync.service'; +import { SyncRunner } from './sync-runner'; function queryBuilder(affected = 1) { const builder = { @@ -67,6 +68,7 @@ function createService(options?: { create: jest.fn().mockImplementation((_entity, value) => value), }; const dataSource = { transaction: jest.fn((callback) => callback(manager)) }; + const runner = new SyncRunner(syncStateRepo as never, syncLogRepo as never); const service = new SyncService( syncLogRepo as never, syncStateRepo as never, @@ -78,6 +80,7 @@ function createService(options?: { attendanceImportService as never, {} as never, dataSource as never, + runner, ); return { service, diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index cfa5a25..5332520 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,16 +1,17 @@ -import { ConflictException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { randomUUID } from 'node:crypto'; import { DataSource, In, Repository } from 'typeorm'; import { SyncLog, SyncState, Student, StudentDingMapping } from '../entities'; import { JinshujuMatchRule, type JinshujuFieldMapping } from '../entities/jinshuju-match-rule.entity'; -import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity'; +import type { SyncPlatform } from '../entities/sync-log.entity'; import { AttendanceImportService } from '../attendance/attendance-import.service'; import { DingTalkService } from '../integration/dingtalk.service'; import { WeComService } from '../integration/wecom.service'; import { JinshujuService } from '../integration/jinshuju.service'; import { syncJinshujuStudents } from '../integration/jinshuju-student-sync'; import { ScheduleSyncService } from './schedule-sync.service'; +import { SyncRunner } from './sync-runner'; +import { getMatchRule, validateMatchRule, extractField } from './jinshuju-rules'; @Injectable() export class SyncService { @@ -32,6 +33,7 @@ export class SyncService { private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, private readonly dataSource: DataSource, + private readonly runner: SyncRunner, ) {} async syncDingTalkStudents( @@ -39,7 +41,7 @@ export class SyncService { createMissing = true, updateProfile = true, ): Promise { - return this.runSync('dingtalk_students', async () => { + return this.runner.run('dingtalk_students', async () => { const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile }); return { recordsCount: result.created + result.updated + (result.matched ?? 0), @@ -56,7 +58,7 @@ export class SyncService { } async syncDingTalkAttendance(): Promise { - return this.runSync('dingtalk_attendance', async (lastSyncAt) => { + return this.runner.run('dingtalk_attendance', async (lastSyncAt) => { const endDate = new Date(); const startDate = lastSyncAt ? new Date(lastSyncAt) : new Date(endDate); if (!lastSyncAt) startDate.setDate(startDate.getDate() - 7); @@ -81,13 +83,13 @@ export class SyncService { } async syncWeCom(): Promise { - return this.runSync('wecom', async () => { + return this.runner.run('wecom', async () => { const result = await this.weComService.syncAll(); return { recordsCount: result.userCount, status: 'success' }; }); } async syncJinshuju(apiKey: string, apiSecret: string, formToken: string): Promise { - return this.runSync('jinshuju', async () => { + return this.runner.run('jinshuju', async () => { const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); const result = await this.dataSource.transaction((manager) => syncJinshujuStudents(manager, entries), @@ -109,14 +111,14 @@ export class SyncService { async previewJinshuju(apiKey: string, apiSecret: string, formToken: string, ruleId?: number) { const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); - const rule = ruleId ? await this.getMatchRule(ruleId, formToken) : null; + const rule = ruleId ? await getMatchRule(this.matchRuleRepo, ruleId, formToken) : null; const map = rule?.mappings ?? { name: 'field_1', phone: 'field_2' }; const parsed = entries .map((e) => ({ serialNumber: e.serial_number, - name: this.extractField(e, map.name), - phone: this.extractField(e, map.phone), + name: extractField(e, map.name), + phone: extractField(e, map.phone), })) .filter((p) => p.name); @@ -177,8 +179,8 @@ export class SyncService { }>, ruleId?: number, ): Promise { - return this.runSync('jinshuju', async () => { - const rule = ruleId ? await this.getMatchRule(ruleId, formToken) : null; + return this.runner.run('jinshuju', async () => { + const rule = ruleId ? await getMatchRule(this.matchRuleRepo, ruleId, formToken) : null; const map = rule?.mappings ?? { name: 'field_1', phone: 'field_2' }; const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); const entryMap = new Map(entries.map((entry) => [entry.serial_number, entry])); @@ -199,7 +201,7 @@ export class SyncService { const mappedValues = Object.fromEntries( Object.entries(map) - .map(([studentField, fieldKey]) => [studentField, this.extractField(entry, fieldKey)]) + .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)]) .filter(([, value]) => value), ); @@ -334,98 +336,6 @@ export class SyncService { return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' } }); } - private async runSync( - platform: SyncPlatform, - operation: (lastSyncAt: Date | null) => Promise<{ - recordsCount: number; - status: Extract; - message?: string; - }>, - ): Promise { - const runId = await this.acquireLease(platform); - let log: SyncLog | undefined; - try { - const lastSyncAt = await this.getLastSyncAt(platform); - log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running'); - const result = await operation(lastSyncAt); - await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() }); - await this.finishSyncLog(log, result.status, result.recordsCount, result.message); - return log; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (log) await this.finishSyncLog(log, 'failed', 0, message); - this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); - throw error; - } finally { - await this.releaseLease(platform, runId); - } - } - - private async acquireLease(platform: SyncPlatform): Promise { - await this.syncStateRepo - .createQueryBuilder() - .insert() - .values({ platform, lastSyncAt: null, runId: null, runningSince: null }) - .orIgnore() - .execute(); - - const runId = randomUUID(); - const result = await this.syncStateRepo - .createQueryBuilder() - .update() - .set({ runId, runningSince: new Date() }) - .where('platform = :platform', { platform }) - .andWhere('(running_since IS NULL OR running_since < :staleBefore)', { - staleBefore: new Date(Date.now() - SyncService.LEASE_MS), - }) - .execute(); - if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`); - return runId; - } - - private async releaseLease(platform: SyncPlatform, runId: string): Promise { - await this.syncStateRepo - .createQueryBuilder() - .update() - .set({ runId: null, runningSince: null }) - .where('platform = :platform AND run_id = :runId', { platform, runId }) - .execute(); - } - - private async getLastSyncAt(platform: SyncPlatform): Promise { - const state = await this.syncStateRepo.findOne({ where: { platform } }); - return state?.lastSyncAt ?? null; - } - - private async createSyncLog( - platform: SyncPlatform, - syncType: SyncType, - status: SyncStatus, - ): Promise { - return this.syncLogRepo.save( - this.syncLogRepo.create({ - platform, - syncType, - status, - recordsCount: 0, - startedAt: new Date(), - }), - ); - } - - private async finishSyncLog( - log: SyncLog, - status: SyncStatus, - recordsCount: number, - errorMessage?: string, - ): Promise { - log.status = status; - log.recordsCount = recordsCount; - log.finishedAt = new Date(); - log.errorMessage = errorMessage ?? null; - await this.syncLogRepo.save(log); - } - // ── Match Rules CRUD ── async listMatchRules(): Promise { @@ -437,7 +347,7 @@ export class SyncService { formToken: string; mappings: JinshujuFieldMapping; }): Promise { - this.validateMatchRule(dto.formToken, dto.mappings); + validateMatchRule(dto.formToken, dto.mappings); return this.matchRuleRepo.save( this.matchRuleRepo.create({ ...dto, @@ -454,7 +364,7 @@ export class SyncService { const rule = await this.matchRuleRepo.findOne({ where: { id } }); if (!rule) throw new NotFoundException('规则不存在'); const mappings = dto.mappings ?? rule.mappings; - this.validateMatchRule(rule.formToken, mappings); + validateMatchRule(rule.formToken, mappings); await this.matchRuleRepo.update(id, { name: dto.name?.trim(), mappings, @@ -466,43 +376,4 @@ export class SyncService { const result = await this.matchRuleRepo.delete(id); if (!result.affected) throw new NotFoundException('规则不存在'); } - - private async getMatchRule(id: number, formToken: string): Promise { - const rule = await this.matchRuleRepo.findOne({ where: { id } }); - if (!rule) throw new NotFoundException('规则不存在'); - if (rule.formToken !== formToken) { - throw new ConflictException('匹配规则不属于当前表单'); - } - return rule; - } - - private validateMatchRule(formToken: string, mappings: JinshujuFieldMapping): void { - if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空'); - if (!mappings.name) throw new ConflictException('匹配规则必须映射姓名字段'); - const allowedStudentFields = new Set([ - 'name', - 'studentNo', - 'phone', - 'idNumber', - 'gender', - 'ethnicity', - 'emergencyContact', - 'emergencyPhone', - ]); - for (const [studentField, fieldKey] of Object.entries(mappings)) { - if (!allowedStudentFields.has(studentField)) { - throw new ConflictException(`不允许映射学生字段:${studentField}`); - } - if (fieldKey && !/^field_\d+$/.test(fieldKey)) { - throw new ConflictException(`无效的金数据字段:${fieldKey}`); - } - } - } - - /** Extract value from a Jinshuju entry by field mapping. */ - private extractField(entry: Record, fieldKey: string | undefined): string { - if (!fieldKey) return ''; - const val = entry[fieldKey]; - return typeof val === 'string' ? val.trim() : ''; - } } diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 270d2de..62a078e 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -1,11 +1,10 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, EntityManager, Repository } from 'typeorm'; +import { DataSource, EntityManager, Repository, In } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { Student } from '../entities/student.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; -import { In } from 'typeorm'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { Room } from '../entities/room.entity'; @@ -58,7 +57,8 @@ export class WalletsService { const ids = rows.map((row) => Number(row.studentId)); const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } }); - const bills = await this.dataSource.getRepository(Bill) + const bills = await this.dataSource + .getRepository(Bill) .createQueryBuilder('bill') .select('bill.studentId', 'studentId') .addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount') @@ -67,7 +67,9 @@ export class WalletsService { .groupBy('bill.studentId') .getRawMany<{ studentId: number; outstandingAmount: string }>(); const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet])); - const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)])); + const debtMap = new Map( + bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]), + ); return rows .map((row) => ({ studentId: Number(row.studentId), @@ -103,7 +105,9 @@ export class WalletsService { async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { const { operationId, ...change } = dto; return this.financialOperations - ? this.financialOperations.run(operationId, 'wallet.change_balance', () => this.changeBalanceOnce(change, recordedBy, operationId)) + ? this.financialOperations.run(operationId, 'wallet.change_balance', () => + this.changeBalanceOnce(change, recordedBy, operationId), + ) : this.changeBalanceOnce(change, recordedBy, operationId); } @@ -114,7 +118,10 @@ export class WalletsService { transactionManager?: EntityManager, ) { const amount = money(dto.amount); - if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { + if ( + !Number.isFinite(dto.amount) || + Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8 + ) { throw new BadRequestException('调账金额最多保留两位小数'); } if (amount === 0) throw new BadRequestException('调账金额不能为 0'); @@ -139,8 +146,11 @@ export class WalletsService { recordedBy: recordedBy || null, }), ); - const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; - const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); + const payments = + amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; + const finalWallet = await manager.findOneByOrFail(StudentWallet, { + studentId: dto.studentId, + }); return { wallet: finalWallet, payments }; }; return transactionManager ? work(transactionManager) : this.dataSource.transaction(work); @@ -148,19 +158,27 @@ export class WalletsService { async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { const { operationId, ...batch } = dto; - const work = () => this.dataSource.transaction(async (manager) => { - const uniqueStudentIds = Array.from(new Set(batch.studentIds)); - const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; - for (const studentId of uniqueStudentIds) { - results.push(await this.changeBalanceOnce({ - studentId, - amount: batch.amount, - type: batch.type, - description: batch.description, - }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager)); - } - return { count: uniqueStudentIds.length, results }; - }); + const work = () => + this.dataSource.transaction(async (manager) => { + const uniqueStudentIds = Array.from(new Set(batch.studentIds)); + const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; + for (const studentId of uniqueStudentIds) { + results.push( + await this.changeBalanceOnce( + { + studentId, + amount: batch.amount, + type: batch.type, + description: batch.description, + }, + recordedBy, + operationId ? `${operationId}:${studentId}` : undefined, + manager, + ), + ); + } + return { count: uniqueStudentIds.length, results }; + }); return this.financialOperations ? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work) : work(); @@ -236,7 +254,11 @@ export class WalletsService { return manager.save(bill); } - private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) { + private async settleOutstandingBills( + manager: EntityManager, + studentId: number, + recordedBy?: number, + ) { const bills = await manager .createQueryBuilder(Bill, 'bill') .where('bill.studentId = :studentId', { studentId }) From 81e622fa095b15cbe83ead9de1e59852a71874f7 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:20:57 +0800 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20=E5=AD=A6=E7=94=9F=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=94=B9=E4=B8=BA=E6=B5=81=E5=BC=8F=E6=8E=92=E7=89=88?= =?UTF-8?q?=E5=B9=B6=E9=9A=90=E8=97=8F=E7=A9=BA=E6=95=B0=E6=8D=AE=E5=8C=BA?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/archive/archive-report.attendance.ts | 15 +- .../src/archive/archive-report.cover.ts | 34 +++-- .../src/archive/archive-report.enrollment.ts | 50 +++---- .../server/src/archive/archive-report.exam.ts | 34 ++--- .../src/archive/archive-report.helpers.ts | 8 +- .../src/archive/archive-report.learning.ts | 128 +++++++----------- .../archive/archive-report.service.spec.ts | 127 ++++++++++++++--- .../src/archive/archive-report.service.ts | 30 +++- .../src/archive/archive-report.styles.ts | 47 +++++-- 9 files changed, 269 insertions(+), 204 deletions(-) diff --git a/apps/server/src/archive/archive-report.attendance.ts b/apps/server/src/archive/archive-report.attendance.ts index 18292f2..0e48e1f 100644 --- a/apps/server/src/archive/archive-report.attendance.ts +++ b/apps/server/src/archive/archive-report.attendance.ts @@ -1,7 +1,9 @@ import { AttendanceRecord } from '../entities/attendance-record.entity'; -import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { esc, sectionFrame, sectionHeader } from './archive-report.helpers'; export function buildAttendance(records: AttendanceRecord[], now: string): string { + if (records.length === 0) return ''; + const present = records.filter((r) => r.status === 'present').length; const absent = records.filter((r) => r.status === 'absent').length; const late = records.filter((r) => r.status === 'late').length; @@ -42,18 +44,11 @@ export function buildAttendance(records: AttendanceRecord[], now: string): strin const chart = renderAttendanceBar(records); const matrix = renderAttendanceMatrix(records); - let extraHtml = ''; - if (records.length === 0) { - extraHtml = ''; - } - - return pageFrame(` - ${pageHeader('出勤记录')} + return sectionFrame(` + ${sectionHeader('出勤记录')} ${metricHtml} - ${extraHtml} ${chart} ${matrix} - ${pageFooter()} `); } diff --git a/apps/server/src/archive/archive-report.cover.ts b/apps/server/src/archive/archive-report.cover.ts index 7ed0618..70f0cb7 100644 --- a/apps/server/src/archive/archive-report.cover.ts +++ b/apps/server/src/archive/archive-report.cover.ts @@ -1,7 +1,7 @@ import { Student } from '../entities/student.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; -import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { esc, sectionFrame, sectionHeader, coverFooter } from './archive-report.helpers'; import { buildEnrollmentSection } from './archive-report.enrollment'; export function buildCover( @@ -9,11 +9,22 @@ export function buildCover( profile: StudentProfile | null, enrollments: StudentEnrollment[], now: string, + tocNames: string[], ): string { const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; - return pageFrame(` - ${pageHeader('封面')} + const tocHtml = + tocNames.length > 0 + ? tocNames + .map( + (name, i) => ` +
${String(i + 1).padStart(2, '0')}${esc(name)}
`, + ) + .join('') + : '
暂无章节
'; + + return sectionFrame(` + ${sectionHeader('封面')}
学生档案报告
生成日期: ${esc(now)}
@@ -40,16 +51,10 @@ export function buildCover(
-
-
01基础信息与报读记录第 2 页
-
02考试成绩总览第 3 页
-
03出勤记录第 4 页
-
04文化课考试成绩第 5 页
-
05学情记录与录取归档第 6 页
-
+
${tocHtml}
恭学教育
- ${pageFooter()} - `); + ${coverFooter()} + `, true); } export function buildBasicInfo( @@ -80,10 +85,9 @@ export function buildBasicInfo( const enrollmentSection = buildEnrollmentSection(enrollments); - return pageFrame(` - ${pageHeader('基础信息')} + return sectionFrame(` + ${sectionHeader('基础信息')} ${infoCards} ${enrollmentSection} - ${pageFooter()} `); } diff --git a/apps/server/src/archive/archive-report.enrollment.ts b/apps/server/src/archive/archive-report.enrollment.ts index 666e47a..6dada58 100644 --- a/apps/server/src/archive/archive-report.enrollment.ts +++ b/apps/server/src/archive/archive-report.enrollment.ts @@ -2,14 +2,10 @@ import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { esc } from './archive-report.helpers'; export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string { - if (enrollments.length === 0) { - return ``; - } + if (enrollments.length === 0) return ''; const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { - if (enrs.length === 0) { - return ``; - } + if (enrs.length === 0) return ''; let rows = ''; for (const e of enrs) { @@ -46,35 +42,21 @@ export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), ); - if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { - let html = - '

报读记录

'; + const cultureTable = renderEnrollmentTable(cultureEnrollments); + const profTable = renderEnrollmentTable(profEnrollments); + const otherTable = renderEnrollmentTable(otherEnrollments); + + let html = '

报读记录

'; + if (cultureTable && profTable) { html += '
'; - - html += '
'; - html += '

文化课报读

'; - html += renderEnrollmentTable(cultureEnrollments); + html += `

文化课报读

${cultureTable}
`; + html += `

专业课报读

${profTable}
`; html += '
'; - - html += '
'; - html += '

专业课报读

'; - html += renderEnrollmentTable(profEnrollments); - html += '
'; - - html += '
'; - - if (otherEnrollments.length > 0) { - html += - '

其他报读

'; - html += renderEnrollmentTable(otherEnrollments); - } - - html += '
'; - return html; + } else { + if (cultureTable) html += `

文化课报读

${cultureTable}`; + if (profTable) html += `

专业课报读

${profTable}`; } - - return `
-

报读记录

- ${renderEnrollmentTable(enrollments)} -
`; + if (otherTable) html += `

其他报读

${otherTable}`; + html += '
'; + return html; } diff --git a/apps/server/src/archive/archive-report.exam.ts b/apps/server/src/archive/archive-report.exam.ts index e3df623..f099c5d 100644 --- a/apps/server/src/archive/archive-report.exam.ts +++ b/apps/server/src/archive/archive-report.exam.ts @@ -1,7 +1,9 @@ import { ExamScore } from '../entities/exam-score.entity'; -import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { esc, sectionFrame, sectionHeader } from './archive-report.helpers'; export function buildExamOverview(exams: ExamScore[], now: string): string { + if (exams.length === 0) return ''; + const cultureExams = exams.filter( (e) => e.examType && e.examType.includes('文化'), ); @@ -64,18 +66,11 @@ export function buildExamOverview(exams: ExamScore[], now: string): string { const scoreTable = renderScoreTable(cultureExams); const trendChart = renderScoreTrendChart(cultureExams); - let extraHtml = ''; - if (cultureExams.length === 0) { - extraHtml = ''; - } - - return pageFrame(` - ${pageHeader('考试成绩总览')} + return sectionFrame(` + ${sectionHeader('考试成绩总览')} ${metricHtml} - ${extraHtml} ${scoreTable} ${trendChart} - ${pageFooter()} `); } @@ -183,19 +178,7 @@ export function buildExamDetail(exams: ExamScore[], now: string): string { (e) => e.examType && e.examType.includes('文化'), ); - if (cultureExams.length === 0) { - return pageFrame(` - ${pageHeader('文化课考试成绩')} -
-
-
${esc(now)} · 系统生成
-
文化课考试成绩
-
-
- - ${pageFooter()} - `); - } + if (cultureExams.length === 0) return ''; // Group by subject const subjectMap = new Map(); @@ -235,8 +218,8 @@ export function buildExamDetail(exams: ExamScore[], now: string): string { `; } - return pageFrame(` - ${pageHeader('文化课考试成绩')} + return sectionFrame(` + ${sectionHeader('文化课考试成绩')}
${esc(now)} · 系统生成
@@ -244,6 +227,5 @@ export function buildExamDetail(exams: ExamScore[], now: string): string {
${subjectCards} - ${pageFooter()} `); } diff --git a/apps/server/src/archive/archive-report.helpers.ts b/apps/server/src/archive/archive-report.helpers.ts index f2ea33f..a905173 100644 --- a/apps/server/src/archive/archive-report.helpers.ts +++ b/apps/server/src/archive/archive-report.helpers.ts @@ -7,14 +7,14 @@ export function esc(value: string): string { .replace(/'/g, '''); } -export function pageFrame(inner: string): string { - return `
${inner}
`; +export function sectionFrame(inner: string, cover = false): string { + return `
${cover ? '
' : ''}${inner}
`; } -export function pageHeader(title: string): string { +export function sectionHeader(title: string): string { return `
恭学教育 · 学生档案${esc(title)}
`; } -export function pageFooter(): string { +export function coverFooter(): string { return ``; } diff --git a/apps/server/src/archive/archive-report.learning.ts b/apps/server/src/archive/archive-report.learning.ts index 2039e9d..db9c133 100644 --- a/apps/server/src/archive/archive-report.learning.ts +++ b/apps/server/src/archive/archive-report.learning.ts @@ -1,85 +1,61 @@ import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; -import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { esc, sectionFrame, sectionHeader } from './archive-report.helpers'; -export function buildLearningAndResult( - learnings: LearningRecord[], - result: ResultArchive | null, - now: string, -): string { - let learningHtml = ''; - if (learnings.length === 0) { - learningHtml = ` -
-
-
${esc(now)} · 系统生成
-
学情记录
-
-
- `; - } else { - const latest = learnings.slice(0, 15); - let rows = ''; - for (const r of latest) { - rows += `
- - - - - `; - } +export function buildLearning(learnings: LearningRecord[], now: string): string { + if (learnings.length === 0) return ''; - learningHtml = ` -
-
-
${esc(now)} · 系统生成
-
学情记录
-
-
-
-

最近学情记录

-
${esc(r.recordDate || '-')}${esc(r.recordType || '-')}${esc((r.content || '-').slice(0, 200))}${esc(r.followUpMethod || '-')}
- - - - - ${rows} -
日期类型内容跟进方式
-
`; + const latest = learnings.slice(0, 15); + let rows = ''; + for (const r of latest) { + rows += ` + ${esc(r.recordDate || '-')} + ${esc(r.recordType || '-')} + ${esc((r.content || '-').slice(0, 200))} + ${esc(r.followUpMethod || '-')} + `; } - let resultHtml = ''; - if (result) { - resultHtml = ` -
-
-
录取归档
-
+ return sectionFrame(` + ${sectionHeader('学情记录')} +
+
+
${esc(now)} · 系统生成
+
学情记录
-
-
-
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
-
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
-
录取状态${esc(result.admissionStatus || '-')}
-
录取院校${esc(result.admittedCollege || '-')}
-
录取专业${esc(result.admittedMajor || '-')}
-
-
-
录取归档信息为最终结果,如有疑问请联系教务处
`; - } else { - resultHtml = ` -
-
-
录取归档
-
-
- `; - } - - return pageFrame(` - ${pageHeader('学情记录与录取归档')} - ${learningHtml} - ${resultHtml} - ${pageFooter()} +
+
+

最近学情记录

+ + + + + + ${rows} +
日期类型内容跟进方式
+
+ `); +} + +export function buildResult(result: ResultArchive | null, now: string): string { + if (!result) return ''; + + return sectionFrame(` + ${sectionHeader('录取归档')} +
+
+
录取归档
+
+
+
+
+
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
+
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
+
录取状态${esc(result.admissionStatus || '-')}
+
录取院校${esc(result.admittedCollege || '-')}
+
录取专业${esc(result.admittedMajor || '-')}
+
+
+
录取归档信息为最终结果,如有疑问请联系教务处
`); } diff --git a/apps/server/src/archive/archive-report.service.spec.ts b/apps/server/src/archive/archive-report.service.spec.ts index 457b337..6ec69b6 100644 --- a/apps/server/src/archive/archive-report.service.spec.ts +++ b/apps/server/src/archive/archive-report.service.spec.ts @@ -1,26 +1,45 @@ import { ArchiveReportService } from './archive-report.service'; +interface MockData { + student?: Record; + profile?: Record | null; + enrollments?: Array>; + exams?: Array>; + learnings?: Array>; + result?: Record | null; + attendances?: Array>; +} + +function makeService(data: MockData = {}): ArchiveReportService { + return new ArchiveReportService( + { findOne: jest.fn().mockResolvedValue(data.profile ?? null) } as never, + { find: jest.fn().mockResolvedValue(data.enrollments ?? []) } as never, + { find: jest.fn().mockResolvedValue(data.exams ?? []) } as never, + { find: jest.fn().mockResolvedValue(data.learnings ?? []) } as never, + { findOne: jest.fn().mockResolvedValue(data.result ?? null) } as never, + { find: jest.fn().mockResolvedValue(data.attendances ?? []) } as never, + { + findOne: jest.fn().mockResolvedValue( + data.student ?? { id: 1, name: '测试学生', studentNo: 'S001' }, + ), + } as never, + ); +} + describe('ArchiveReportService retired profile fields', () => { it('does not render the retired campus field in a student report', async () => { - const service = new ArchiveReportService( - { findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never, - { find: jest.fn().mockResolvedValue([]) } as never, - { find: jest.fn().mockResolvedValue([]) } as never, - { find: jest.fn().mockResolvedValue([]) } as never, - { findOne: jest.fn().mockResolvedValue(null) } as never, - { find: jest.fn().mockResolvedValue([]) } as never, - { - findOne: jest.fn().mockResolvedValue({ - id: 1, - name: '测试学生', - gender: '男', - phone: '', - ethnicity: '', - emergencyContact: '', - emergencyPhone: '', - }), - } as never, - ); + const service = makeService({ + profile: { campusLocation: '旧校区', grade: '高三' }, + student: { + id: 1, + name: '测试学生', + gender: '男', + phone: '', + ethnicity: '', + emergencyContact: '', + emergencyPhone: '', + }, + }); const html = await service.generateReportHtml(1); @@ -29,3 +48,73 @@ describe('ArchiveReportService retired profile fields', () => { expect(html).toContain('高三'); }); }); + +describe('ArchiveReportService empty sections', () => { + it('hides empty sections and removes fixed TOC page numbers', async () => { + const service = makeService({ profile: { grade: '高三' } }); + const html = await service.generateReportHtml(1); + + expect(html).toContain('学生档案报告'); + expect(html).toContain('基础信息'); + expect(html).not.toContain('考试成绩总览'); + expect(html).not.toContain('出勤记录'); + expect(html).not.toContain('文化课考试成绩'); + expect(html).not.toContain('学情记录'); + expect(html).not.toContain('录取归档'); + expect(html).not.toContain('第 2 页'); + expect(html).not.toContain('暂无'); + }); + + it('renders sections with data and lists only those sections in the TOC', async () => { + const service = makeService({ + profile: { grade: '高三' }, + enrollments: [{ courseCategory: '文化课', classType: '全日制' }], + exams: [ + { + examType: '文化课月考', + examName: '一月月考', + subject: '数学', + score: 88, + examDate: '2026-01-10', + }, + ], + attendances: [ + { attendanceDate: '2026-01-12', session: '上午', status: 'present' }, + ], + learnings: [ + { + recordDate: '2026-01-13', + recordType: '回访', + content: '状态良好', + followUpMethod: '电话', + }, + ], + result: { + cultureFinalScore: 90, + professionalFinalScore: 85, + admissionStatus: '录取', + admittedCollege: '示例大学', + admittedMajor: '计算机', + }, + }); + + const html = await service.generateReportHtml(1); + + expect(html).toContain('考试成绩总览'); + expect(html).toContain('出勤记录'); + expect(html).toContain('文化课考试成绩'); + expect(html).toContain('学情记录'); + expect(html).toContain('录取归档'); + expect(html).toContain('

报读记录

'); + expect(html).not.toContain('第 1 页'); + expect(html).not.toContain('第 2 页'); + }); + + it('omits the enrollment card when there are no enrollments', async () => { + const service = makeService({ profile: { grade: '高三' } }); + const html = await service.generateReportHtml(1); + + expect(html).not.toContain('

报读记录

'); + expect(html).not.toContain('暂无报读记录'); + }); +}); diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 6994219..5542940 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -13,7 +13,7 @@ import { esc } from './archive-report.helpers'; import { buildCover, buildBasicInfo } from './archive-report.cover'; import { buildExamOverview, buildExamDetail } from './archive-report.exam'; import { buildAttendance } from './archive-report.attendance'; -import { buildLearningAndResult } from './archive-report.learning'; +import { buildLearning, buildResult } from './archive-report.learning'; interface ReportData { student: Student; @@ -71,17 +71,33 @@ export class ArchiveReportService { day: 'numeric', }); + const sections = [ + { + name: enrollments.length > 0 ? '基础信息与报读记录' : '基础信息', + html: buildBasicInfo(student, profile, enrollments, now), + }, + { name: '考试成绩总览', html: buildExamOverview(exams, now) }, + { name: '出勤记录', html: buildAttendance(attendances, now) }, + { name: '文化课考试成绩', html: buildExamDetail(exams, now) }, + { name: '学情记录', html: buildLearning(learnings, now) }, + { name: '录取归档', html: buildResult(result, now) }, + ].filter((section) => section.html.length > 0); + + const cover = buildCover( + student, + profile, + enrollments, + now, + sections.map((section) => section.name), + ); + return ` 学生档案报告 - ${esc(name)} -${buildCover(student, profile, enrollments, now)} -${buildBasicInfo(student, profile, enrollments, now)} -${buildExamOverview(exams, now)} -${buildAttendance(attendances, now)} -${buildExamDetail(exams, now)} -${buildLearningAndResult(learnings, result, now)} +${cover} +${sections.map((section) => section.html).join('\n')} `; } } diff --git a/apps/server/src/archive/archive-report.styles.ts b/apps/server/src/archive/archive-report.styles.ts index fa21ea8..54d2bde 100644 --- a/apps/server/src/archive/archive-report.styles.ts +++ b/apps/server/src/archive/archive-report.styles.ts @@ -6,10 +6,14 @@ export const ARCHIVE_REPORT_CSS = ` font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; } - .page { - position: relative; width: 210mm; height: 297mm; + .section { + position: relative; width: 210mm; max-width: 100%; margin: 0 auto 18px; padding: 14mm 15mm 10mm; - overflow: hidden; background: #fff; page-break-after: always; + background: #fff; + } + .section-cover { + height: 297mm; overflow: hidden; + page-break-after: always; break-after: page; } .frame { position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; @@ -17,6 +21,7 @@ export const ARCHIVE_REPORT_CSS = ` .header { position: relative; z-index: 1; display: flex; align-items: center; height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; + page-break-after: avoid; break-after: avoid; } .logo { width: 24px; height: 24px; border-radius: 6px; @@ -33,11 +38,13 @@ export const ARCHIVE_REPORT_CSS = ` font-size: 10px; color: #667085; } h1, h2, h3, p { margin: 0; } + h1, h2, h3 { page-break-after: avoid; break-after: avoid; } .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } .source { font-size: 12px; color: #667085; padding-bottom: 2px; } .title-row { display: flex; align-items: flex-end; justify-content: space-between; margin: 26px 0 17px; + page-break-after: avoid; break-after: avoid; } .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } @@ -60,7 +67,7 @@ export const ARCHIVE_REPORT_CSS = ` .value { font-size: 14px; line-height: 1.5; font-weight: 700; } .toc { margin-top: 58px; } .toc-row { - display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; + display: grid; grid-template-columns: 48px 1fr; align-items: center; height: 47px; border-bottom: 1px solid #cfe0f2; } .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } @@ -70,9 +77,18 @@ export const ARCHIVE_REPORT_CSS = ` position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; font-size: 56px; font-weight: 900; writing-mode: vertical-rl; } - .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } - .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } - .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } + .grid-2 { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; + page-break-inside: avoid; break-inside: avoid; + } + .grid-4 { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + page-break-inside: avoid; break-inside: avoid; + } + .card { + border: 1px solid #cfe0f2; padding: 14px; background: #fff; + page-break-inside: avoid; break-inside: avoid; + } .card h3 { font-size: 16px; margin-bottom: 14px; } .data-table { width: 100%; border-collapse: collapse; table-layout: fixed; @@ -86,8 +102,10 @@ export const ARCHIVE_REPORT_CSS = ` } .data-table td { overflow-wrap: anywhere; word-break: break-word; } .data-table .nowrap { white-space: nowrap; } + .data-table tr { page-break-inside: avoid; break-inside: avoid; } .metric { min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; + page-break-inside: avoid; break-inside: avoid; } .metric .label { margin-bottom: 7px; } .metric strong { @@ -107,13 +125,16 @@ export const ARCHIVE_REPORT_CSS = ` .note { margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; + page-break-inside: avoid; break-inside: avoid; } - .banner-note { - margin-top: 12px; padding: 11px 16px; background: #eef5ff; - color: #173f6f; font-size: 12px; line-height: 1.7; + .line-chart { + width: 100%; height: 180px; display: block; + page-break-inside: avoid; break-inside: avoid; + } + .bar-chart { + width: 100%; height: 150px; display: block; + page-break-inside: avoid; break-inside: avoid; } - .line-chart { width: 100%; height: 180px; display: block; } - .bar-chart { width: 100%; height: 150px; display: block; } .status { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; @@ -137,6 +158,6 @@ export const ARCHIVE_REPORT_CSS = ` .muted { color: #667085; } @media print { body { background: #fff; } - .page { margin: 0; box-shadow: none; } + .section { margin: 0; box-shadow: none; } } `; From 47720a8fcc9d302f30bff5c3acbcc2597edacd55 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:43:13 +0800 Subject: [PATCH 13/19] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20SQLite?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BB=85=E4=BF=9D=E7=95=99=20MyS?= =?UTF-8?q?QL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- apps/server/datasource.ts | 16 +- apps/server/package.json | 4 - .../ai-chat-enhancement.migration.spec.ts | 64 -- .../src/ai-chat/ai-chat.migration.spec.ts | 46 -- .../ai-review-enlarge.migration.spec.ts | 63 -- .../src/ai-chat/ai-review.service.spec.ts | 693 ------------------ .../server/src/ai-config/ai-config.service.ts | 3 +- apps/server/src/app.module.ts | 26 +- .../attendance/attendance-lesson.service.ts | 4 +- .../attendance-workflow.integration.spec.ts | 332 --------- .../attendance.lesson-session.spec.ts | 6 +- .../database/attendance-fk-restrict.spec.ts | 380 ---------- .../src/database/database-migrations.ai.ts | 27 +- .../database-migrations.attendance.ts | 117 +-- .../database/database-migrations.backfill.ts | 25 +- .../database-migrations.class-student.spec.ts | 2 +- ...tabase-migrations.classroom-status.spec.ts | 2 +- ...database-migrations.deposit-refund.spec.ts | 2 +- .../database-migrations.room-gender.spec.ts | 2 +- .../database/database-migrations.schema.ts | 20 +- .../src/database/database-migrations.spec.ts | 107 +-- apps/server/src/migration-runner.ts | 5 - .../1784900000000-EnlargeAiReviewSections.ts | 5 +- .../occupancies.boundaries.spec.ts | 4 +- .../occupancies/occupancies.service.spec.ts | 2 +- .../src/occupancies/occupancies.service.ts | 6 +- apps/server/src/occupancies/occupancy-lock.ts | 8 +- .../rooms/room-inspections.service.spec.ts | 2 +- .../src/rooms/room-inspections.service.ts | 4 +- .../src/wallets/wallets.service.spec.ts | 16 - apps/server/src/wallets/wallets.service.ts | 4 +- package-lock.json | 50 +- package.json | 2 +- 技术文档.md | 12 +- 35 files changed, 111 insertions(+), 1961 deletions(-) delete mode 100644 apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts delete mode 100644 apps/server/src/ai-chat/ai-chat.migration.spec.ts delete mode 100644 apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts delete mode 100644 apps/server/src/attendance/attendance-workflow.integration.spec.ts delete mode 100644 apps/server/src/database/attendance-fk-restrict.spec.ts diff --git a/README.md b/README.md index 6ec508d..c8bcc0f 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ ``` 前端 (React + Vite) 后端 (NestJS) 数据库 ┌─────────────────┐ ┌──────────────────┐ ┌──────────┐ -│ React 19 │ │ NestJS 11 │ │ SQLite │ -│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ (开发) │ -│ ECharts │ │ JWT + Passport │ │ MySQL 8 │ -│ Vite 8 │ │ ExcelJS + PDFKit │ │ (生产) │ +│ React 19 │ │ NestJS 11 │ │ MySQL 8 │ +│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ │ +│ ECharts │ │ JWT + Passport │ │ │ +│ Vite 8 │ │ ExcelJS + PDFKit │ │ │ └─────────────────┘ └──────────────────┘ └──────────┘ ``` @@ -37,6 +37,7 @@ - Node.js >= 18 - npm >= 9 +- MySQL 8.0 ### 后端启动 @@ -93,7 +94,7 @@ docker-compose up -d # 一键启动 MySQL + 后端 + 前端 | 配置项 | 说明 | 默认值 | |--------|------|--------| -| `DB_TYPE` | 数据库类型 | `mysql` | +| `DB_TYPE` | 数据库类型(仅支持 MySQL) | `mysql` | | `DB_HOST` | 数据库地址 | `localhost` | | `DB_PORT` | 数据库端口 | `3306` | | `DB_USERNAME` | 数据库用户名 | `dorm_billing` | diff --git a/apps/server/datasource.ts b/apps/server/datasource.ts index 7c1ea88..3263814 100644 --- a/apps/server/datasource.ts +++ b/apps/server/datasource.ts @@ -6,16 +6,14 @@ import { join } from 'path'; const root = process.cwd(); config({ path: join(root, '.env') }); -const dbType = process.env.DB_TYPE || 'sqlite'; - export default new DataSource({ - type: dbType === 'mysql' ? 'mysql' : 'better-sqlite3', - host: dbType === 'mysql' ? (process.env.DB_HOST || 'localhost') : undefined, - port: dbType === 'mysql' ? (Number(process.env.DB_PORT) || 3306) : undefined, - username: dbType === 'mysql' ? (process.env.DB_USERNAME || 'root') : undefined, - password: dbType === 'mysql' ? (process.env.DB_PASSWORD || '') : undefined, - database: process.env.DB_DATABASE || (dbType === 'mysql' ? 'dorm_billing' : 'dorm_billing.db'), - charset: dbType === 'mysql' ? 'utf8mb4' : undefined, + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 3306, + username: process.env.DB_USERNAME || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_DATABASE || 'dorm_billing', + charset: 'utf8mb4', entities: [join(root, 'src/**/*.entity.ts')], migrations: [join(root, 'src/migrations/*.ts')], }); diff --git a/apps/server/package.json b/apps/server/package.json index 1069ea5..beea8ec 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -65,16 +65,12 @@ "rxjs": "^7.8.1", "typeorm": "^0.3.31" }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0" - }, "devDependencies": { "@eslint/js": "^9.18.0", "@gongxue/typescript-config": "*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", diff --git a/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts b/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts deleted file mode 100644 index 3c21ee2..0000000 --- a/apps/server/src/ai-chat/ai-chat-enhancement.migration.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddAiChat1784780000000 } from '../migrations/1784780000000-AddAiChat'; -import { EnhanceAiChatForAntDesignX1784860000000 } from '../migrations/1784860000000-EnhanceAiChatForAntDesignX'; -import { DropAiMessageFeedback1784920000000 } from '../migrations/1784920000000-DropAiMessageFeedback'; - -describe('EnhanceAiChatForAntDesignX1784860000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddAiChat1784780000000, EnhanceAiChatForAntDesignX1784860000000], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - await dataSource.query( - 'CREATE TABLE ai_config (id integer PRIMARY KEY AUTOINCREMENT, singleton_key varchar(20) NOT NULL)', - ); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('adds Ant Design X chat fields and attachment relations', async () => { - await dataSource.runMigrations(); - const runner = dataSource.createQueryRunner(); - for (const table of ['ai_attachments', 'ai_message_attachments']) { - expect(await runner.hasTable(table)).toBe(true); - } - expect(await runner.hasColumn('ai_config', 'supports_vision')).toBe(true); - expect(await runner.hasColumn('ai_conversations', 'locked_skill_key')).toBe(true); - expect(await runner.hasColumn('ai_messages', 'feedback')).toBe(true); - expect(await runner.hasColumn('ai_tool_runs', 'skill_key')).toBe(true); - await runner.release(); - }); - - it('drops the removed like/dislike feedback columns', async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [ - AddAiChat1784780000000, - EnhanceAiChatForAntDesignX1784860000000, - DropAiMessageFeedback1784920000000, - ], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - await dataSource.query( - 'CREATE TABLE ai_config (id integer PRIMARY KEY AUTOINCREMENT, singleton_key varchar(20) NOT NULL)', - ); - await dataSource.runMigrations(); - const runner = dataSource.createQueryRunner(); - expect(await runner.hasColumn('ai_messages', 'feedback')).toBe(false); - expect(await runner.hasColumn('ai_messages', 'feedback_reason')).toBe(false); - await runner.release(); - }); -}); diff --git a/apps/server/src/ai-chat/ai-chat.migration.spec.ts b/apps/server/src/ai-chat/ai-chat.migration.spec.ts deleted file mode 100644 index 2087572..0000000 --- a/apps/server/src/ai-chat/ai-chat.migration.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddAiChat1784780000000 } from '../migrations/1784780000000-AddAiChat'; - -describe('AddAiChat1784780000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddAiChat1784780000000], - }); - await dataSource.initialize(); - await dataSource.query( - 'CREATE TABLE users (id integer PRIMARY KEY AUTOINCREMENT, username varchar(100) NOT NULL)', - ); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('创建会话、消息和工具记录表,并按会话级联删除', async () => { - await dataSource.runMigrations(); - - for (const table of ['ai_conversations', 'ai_messages', 'ai_tool_runs']) { - expect(await dataSource.createQueryRunner().hasTable(table)).toBe(true); - } - - await dataSource.query("INSERT INTO users (username) VALUES ('tester')"); - await dataSource.query( - "INSERT INTO ai_conversations (user_id, title) VALUES (1, '测试会话')", - ); - await dataSource.query( - "INSERT INTO ai_messages (conversation_id, role, content) VALUES (1, 'assistant', '回答')", - ); - await dataSource.query( - "INSERT INTO ai_tool_runs (message_id, tool_call_id, tool_name, status) VALUES (1, 'call_1', 'search_students', 'success')", - ); - - await dataSource.query('DELETE FROM ai_conversations WHERE id = 1'); - - expect(await dataSource.query('SELECT id FROM ai_messages')).toEqual([]); - expect(await dataSource.query('SELECT id FROM ai_tool_runs')).toEqual([]); - }); -}); diff --git a/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts b/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts deleted file mode 100644 index 6db122c..0000000 --- a/apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { DataSource } from 'typeorm'; -import { AddA2UiReviews1784880000000 } from '../migrations/1784880000000-AddA2UiReviews'; -import { EnlargeAiReviewSections1784900000000 } from '../migrations/1784900000000-EnlargeAiReviewSections'; - -describe('EnlargeAiReviewSections1784900000000', () => { - let dataSource: DataSource; - - beforeEach(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - migrations: [AddA2UiReviews1784880000000, EnlargeAiReviewSections1784900000000], - }); - await dataSource.initialize(); - await dataSource.query(` - CREATE TABLE ai_messages ( - id integer PRIMARY KEY AUTOINCREMENT, - conversation_id integer NOT NULL, - role varchar(20) NOT NULL, - content text, - reasoning_content text, - status varchar(20) NOT NULL, - error_code varchar(50), - reply_to_message_id integer, - feedback varchar(10), - feedback_reason varchar(500), - metadata text, - created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - }); - - afterEach(async () => { - if (dataSource.isInitialized) await dataSource.destroy(); - }); - - it('迁移后可保存远超 256KB 的预览数据,且再次执行幂等', async () => { - await dataSource.runMigrations(); - await dataSource.runMigrations(); - - await dataSource.query( - `INSERT INTO ai_messages (conversation_id, role, content, status) - VALUES (1, 'assistant', '', 'completed')`, - ); - const big = '中'.repeat(300 * 1024); - await dataSource.query( - `INSERT INTO ai_reviews - (id, conversation_id, user_id, assistant_message_id, title, sections_json, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ['review-1', 1, 1, 1, '大体积导入', big, 'pending'], - ); - const rows: Array<{ sections_json: string }> = await dataSource.query( - 'SELECT sections_json FROM ai_reviews WHERE id = ?', - ['review-1'], - ); - expect(rows[0].sections_json.length).toBe(big.length); - - const runner = dataSource.createQueryRunner(); - expect(await runner.hasColumn('ai_reviews', 'sections_json')).toBe(true); - await runner.release(); - }); -}); diff --git a/apps/server/src/ai-chat/ai-review.service.spec.ts b/apps/server/src/ai-chat/ai-review.service.spec.ts index 4b327a4..1ce6b94 100644 --- a/apps/server/src/ai-chat/ai-review.service.spec.ts +++ b/apps/server/src/ai-chat/ai-review.service.spec.ts @@ -637,696 +637,3 @@ describe('AiReviewService', () => { }); }); -describe('AiReviewService.submit (real sqlite transaction)', () => { - let dataSource: DataSource; - let service: AiReviewService; - let hostOrg: Organization; - let namedOrg: Organization; - let assistantMessageId: number; - - beforeAll(async () => { - dataSource = new DataSource({ - type: 'better-sqlite3', - database: ':memory:', - entities: Object.values(allEntities).filter( - (value): value is new (...args: unknown[]) => unknown => - typeof value === 'function', - ), - synchronize: true, - }); - await dataSource.initialize(); - const orgRepo = dataSource.getRepository(Organization); - hostOrg = await orgRepo.save( - orgRepo.create({ publicId: 'host', code: 'HOST', name: '恭学总校', isHost: true }), - ); - namedOrg = await orgRepo.save( - orgRepo.create({ publicId: 'org-a', code: 'ORG_A', name: '东校区' }), - ); - const userRepo = dataSource.getRepository(User); - const user = await userRepo.save( - userRepo.create({ username: 'review-tester', passwordHash: 'x' }), - ); - const conversationRepo = dataSource.getRepository(AiConversation); - const conversation = await conversationRepo.save( - conversationRepo.create({ userId: user.id, title: '测试会话' }), - ); - const messageRepo = dataSource.getRepository(AiMessage); - const assistant = await messageRepo.save( - messageRepo.create({ - conversationId: conversation.id, - role: 'assistant', - content: '', - status: 'completed', - }), - ); - assistantMessageId = assistant.id; - const reviewRepo = dataSource.getRepository(AiReview); - service = new AiReviewService(reviewRepo, dataSource); - }); - - afterAll(async () => { - await dataSource.destroy(); - }); - - it('按 学生→宿舍→换宿 顺序事务入库,并收集逐行问题', async () => { - const studentRepo = dataSource.getRepository(Student); - const roomRepo = dataSource.getRepository(Room); - const bedRepo = dataSource.getRepository(Bed); - const occRepo = dataSource.getRepository(Occupancy); - - const existing = await studentRepo.save( - studentRepo.create({ - name: '老王', - phone: '13800138000', - studentNo: 'S001', - organizationId: hostOrg.id, - }), - ); - const oldRoom = await roomRepo.save( - roomRepo.create({ roomNumber: '1-101', capacity: 4, status: 'available' }), - ); - await bedRepo.save( - Array.from({ length: 4 }, (_, index) => - bedRepo.create({ roomId: oldRoom.id, bedNumber: `${index + 1}号床` }), - ), - ); - await occRepo.save( - occRepo.create({ - studentId: existing.id, - roomId: oldRoom.id, - checkInDate: '2026-01-05', - billingStartDate: '2026-01-05', - stayType: 'short', - responsibleOrganizationId: hostOrg.id, - }), - ); - - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '开学导入', - summary: 'Excel 导入', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'organization', title: '机构' }, - ], - rows: [ - { name: '张三', phone: '13900139000', organization: '东校区' }, - { name: '老王', phone: '13800138000', organization: '恭学总校' }, - { name: '李四', phone: '13700137000', organization: '不存在的机构' }, - ], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '3-301' }, { roomNumber: '1-101' }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentNo', title: '学号' }, - { key: 'oldRoom', title: '原宿舍' }, - { key: 'newRoom', title: '目标宿舍' }, - { key: 'transferDate', title: '换宿日期' }, - ], - rows: [ - { studentNo: 'S001', oldRoom: '1-101', newRoom: '3-301', transferDate: '2026-03-01' }, - ], - issues: [], - }, - ], - }, - ); - - const { review: submittedReview, result } = await service.submitAll(review.id, 7); - expect(result.students.created).toBe(2); - expect(result.students.skipped).toBe(1); - expect(result.rooms.created).toBe(1); - expect(result.rooms.skipped).toBe(1); - expect(result.transfers.completed).toBe(1); - expect(result.transfers.skipped).toBe(0); - - const createdStudent = await studentRepo.findOne({ where: { phone: '13900139000' } }); - expect(createdStudent?.name).toBe('张三'); - expect(createdStudent?.organizationId).toBe(namedOrg.id); - const hostFallbackStudent = await studentRepo.findOne({ where: { phone: '13700137000' } }); - expect(hostFallbackStudent?.organizationId).toBe(hostOrg.id); - - const newRoom = await roomRepo.findOne({ where: { roomNumber: '3-301' } }); - expect(newRoom?.capacity).toBe(4); - expect(await bedRepo.count({ where: { roomId: newRoom!.id } })).toBe(4); - - const oldOcc = await occRepo.findOne({ - where: { studentId: existing.id, roomId: oldRoom.id }, - }); - expect(oldOcc?.checkOutDate).toBe('2026-03-01'); - const newOcc = await occRepo.findOne({ - where: { studentId: existing.id, roomId: newRoom!.id, checkOutDate: null }, - }); - expect(newOcc?.checkInDate).toBe('2026-03-01'); - expect(newOcc?.billingStartDate).toBe('2026-03-02'); - - expect(submittedReview.status).toBe('submitted'); - expect(submittedReview.submittedAt).toBeInstanceOf(Date); - const savedSections = service.parseSections(submittedReview.sectionsJson); - const studentSection = savedSections.find((section) => section.key === 'students'); - expect(studentSection?.issues).toEqual( - expect.arrayContaining(['学生「老王」已存在(按手机号/学号匹配),未重复创建']), - ); - expect(submittedReview.resultSummary).toContain('成功导入学生 2 人'); - }); - - it('入住记录分表:学生和宿舍不存在时自动创建后写入住记录', async () => { - const studentRepo = dataSource.getRepository(Student); - const roomRepo = dataSource.getRepository(Room); - const occRepo = dataSource.getRepository(Occupancy); - - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '入住导入', - summary: '宿舍入住记录', - sections: [ - { - key: 'checkins', - title: '入住记录', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: '於嘉丽', - phone: '13611112222', - roomNumber: '5-501', - checkInDate: '2026-08-01', - }, - { - name: '重复学生', - phone: '13611112222', - roomNumber: '5-502', - checkInDate: '2026-08-01', - }, - ], - issues: [], - }, - ], - }, - ); - - const { result } = await service.submitAll(review.id, 7); - expect(result.checkins.completed).toBe(1); - expect(result.checkins.skipped).toBe(1); - - const created = await studentRepo.findOne({ where: { phone: '13611112222' } }); - expect(created?.name).toBe('於嘉丽'); - expect(created?.organizationId).toBe(hostOrg.id); - const room = await roomRepo.findOne({ where: { roomNumber: '5-501' } }); - expect(room?.capacity).toBe(4); - const occupancy = await occRepo.findOne({ where: { studentId: created!.id } }); - expect(occupancy?.checkInDate).toBe('2026-08-01'); - expect(occupancy?.roomId).toBe(room!.id); - }); - - it('分步确认:依赖未满足拒绝,重复确认 409,全部完成后整卡提交', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '分步导入', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '分步学生', phone: '13511112222' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-901' }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [{ key: 'studentNo', title: '学号' }], - rows: [{ studentNo: 'NOPE' }], - issues: [], - }, - ], - }, - ); - - await expect(service.submitSection(review.id, 7, 'transfers')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - - const studentsStep = await service.submitSection(review.id, 7, 'students'); - expect(studentsStep.result).toMatchObject({ created: 1, skipped: 0 }); - expect( - service - .parseSections(studentsStep.review.sectionsJson) - .find((section) => section.key === 'students')?.status, - ).toBe('submitted'); - - await expect(service.submitSection(review.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已确认导入'), - }); - - await service.submitSection(review.id, 7, 'rooms'); - const transferStep = await service.submitSection(review.id, 7, 'transfers'); - expect(service.parseSections(transferStep.review.sectionsJson).map((s) => s.status)).toEqual([ - 'submitted', - 'submitted', - 'submitted', - ]); - expect(transferStep.review.status).toBe('submitted'); - expect(transferStep.review.submittedAt).toBeInstanceOf(Date); - }); - - it('依赖按类型整组判断:同类型全部 sheet 提交后才允许换宿', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '多 sheet 依赖', - sections: [ - { - key: 'students_a', - title: '学生 A', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '甲', phone: '13511112222' }], - issues: [], - }, - { - key: 'students_b', - title: '学生 B', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '乙', phone: '13511113333' }], - issues: [], - }, - { - key: 'rooms_9', - title: '9 号楼宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-901' }], - issues: [], - }, - { - key: 'checkins_active', - type: 'checkins', - title: '在住记录', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: '丙', - phone: '13511115555', - roomNumber: '9-903', - checkInDate: '2026-08-01', - }, - ], - issues: [], - }, - { - key: 'transfers_9', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentPhone', title: '手机号' }, - { key: 'newRoom', title: '目标宿舍' }, - { key: 'transferDate', title: '换宿日期' }, - ], - rows: [{ studentPhone: '13511115555', newRoom: '9-901', transferDate: '2026-08-10' }], - issues: [], - }, - ], - }, - ); - - await expect(service.submitSection(review.id, 7, 'transfers_9')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - - await service.submitSection(review.id, 7, 'students_a'); - await expect(service.submitSection(review.id, 7, 'transfers_9')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 2 步'), - }); - - await service.submitSection(review.id, 7, 'students_b'); - await service.submitSection(review.id, 7, 'rooms_9'); - await service.submitSection(review.id, 7, 'checkins_active'); - const transferStep = await service.submitSection(review.id, 7, 'transfers_9'); - expect(transferStep.result).toMatchObject({ completed: 1, skipped: 0 }); - const statuses = service - .parseSections(transferStep.review.sectionsJson) - .map((section) => section.status); - expect(statuses).toEqual(['submitted', 'submitted', 'submitted', 'submitted', 'submitted']); - }); - - it('组确认按 sheet 逐张导入,成功后整组状态已导入', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '整组入住确认', - sections: Array.from({ length: 2 }, (_, i) => ({ - key: `checkins_group_${i + 1}`, - type: 'checkins', - title: `入住表${i + 1}`, - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'roomNumber', title: '宿舍号' }, - { key: 'checkInDate', title: '入住日期' }, - ], - rows: [ - { - name: `入住学生${i + 1}`, - phone: `1360000000${i + 1}`, - roomNumber: `5-50${i + 1}`, - checkInDate: '2026-08-01', - }, - ], - issues: [], - })), - }, - ); - - const { review: grouped } = await service.submitGroup(review.id, 7, 'checkins'); - const sections = service.parseSections(grouped.sectionsJson); - expect(sections.map((section) => section.status)).toEqual(['submitted', 'submitted']); - expect(grouped.status).toBe('submitted'); - expect( - await dataSource.getRepository(Student).count({ - where: { phone: '13600000001' }, - }), - ).toBe(1); - expect( - await dataSource.getRepository(Student).count({ - where: { phone: '13600000002' }, - }), - ).toBe(1); - expect(await dataSource.getRepository(Room).count({ where: { roomNumber: '5-501' } })).toBe(1); - expect(await dataSource.getRepository(Room).count({ where: { roomNumber: '5-502' } })).toBe(1); - }); - - it('全部确认时按类型合并多张 sheet 的统计数量', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '多 sheet 聚合', - sections: Array.from({ length: 2 }, (_, i) => ({ - key: `students_batch_${i + 1}`, - type: 'students', - title: `学生表${i + 1}`, - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [ - { - name: `批量学生${i + 1}`, - phone: `1370000000${i + 1}`, - }, - ], - issues: [], - })), - }, - ); - - const { result } = await service.submitAll(review.id, 7); - expect(result.students.created).toBe(2); - expect(result.students.skipped).toBe(0); - expect(result.message).toContain('成功导入学生 2 人'); - }); - - it('组确认依赖未满足时返回 409,不导入任何 sheet', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '组依赖校验', - sections: [ - { - key: 'students_a', - title: '学生 A', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '甲' }], - issues: [], - }, - { - key: 'transfers_a', - title: '换宿 A', - kind: 'table', - columns: [{ key: 'studentPhone', title: '手机号' }], - rows: [{ studentPhone: '13511114444' }], - issues: [], - }, - ], - }, - ); - await expect(service.submitGroup(review.id, 7, 'transfers')).rejects.toMatchObject({ - message: expect.stringContaining('请先确认第 1 步'), - }); - const sections = service.parseSections((await service.findOwned(review.id, 7)).sectionsJson); - expect(sections.map((section) => section.status)).toEqual(['pending', 'pending']); - }); - - it('单步确认部分成功时持久化 resultSummary 与问题', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '部分成功', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [ - { name: '新学生', phone: '13522223333' }, - { name: '重复学生', phone: '13522223333' }, - ], - issues: [], - }, - ], - }, - ); - - const step = await service.submitSection(review.id, 7, 'students'); - expect(step.result).toMatchObject({ created: 1, skipped: 1 }); - const saved = await service.findOwned(review.id, 7); - const section = service.parseSections(saved.sectionsJson)[0]; - expect(section.status).toBe('submitted'); - expect(section.resultSummary).toContain('成功导入学生 1 人,跳过 1 条'); - expect(section.issues).toEqual( - expect.arrayContaining([expect.stringContaining('同一批次中的其他学生')]), - ); - expect(saved.status).toBe('submitted'); - }); - - it('全部确认按固定依赖顺序提交,不受 sections 原始顺序影响', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '乱序导入', - sections: [ - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [{ key: 'studentNo', title: '学号' }], - rows: [{ studentNo: 'NOPE' }], - issues: [], - }, - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '乱序学生', phone: '13544445555' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-902' }], - issues: [], - }, - ], - }, - ); - - const { review: completed } = await service.submitAll(review.id, 7); - expect(completed.status).toBe('submitted'); - expect(service.parseSections(completed.sectionsJson).map((section) => section.status)).toEqual([ - 'submitted', - 'submitted', - 'submitted', - ]); - }); - - it('旧数据缺少 section status 字段时默认 pending 并可继续确认', async () => { - const review = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: '旧数据兼容', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '旧数据学生', phone: '13533334444' }], - issues: [], - }, - ], - }, - ); - const legacySections = service - .parseSections(review.sectionsJson) - .map( - ({ status: _status, resultSummary: _result, submittedAt: _at, type: _type, ...rest }) => - rest, - ); - review.sectionsJson = JSON.stringify(legacySections); - await dataSource.getRepository(AiReview).save(review); - - const step = await service.submitSection(review.id, 7, 'students'); - expect(step.result).toMatchObject({ created: 1, skipped: 0 }); - const reloaded = service.parseSections((await service.findOwned(review.id, 7)).sectionsJson)[0]; - expect(reloaded.status).toBe('submitted'); - }); - - it('同会话生成新预览后旧预览过期,且所有确认入口拒绝', async () => { - const conversationId = 9001; - const first = await service.createReview( - { ...baseArgs, conversationId, assistantMessageId }, - { - title: '旧预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '旧学生', phone: '13711110001' }], - issues: [], - }, - ], - }, - ); - const second = await service.createReview( - { ...baseArgs, conversationId, assistantMessageId }, - { - title: '新预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '新学生', phone: '13711110002' }], - issues: [], - }, - ], - }, - ); - - const expired = await service.expirePreviousReviews(7, conversationId, second.id); - expect(expired.map((review) => review.id)).toEqual([first.id]); - expect((await service.findOwned(first.id, 7)).status).toBe('expired'); - expect((await service.findOwned(second.id, 7)).status).toBe('pending'); - - await expect(service.findOwnedPending(first.id, 7)).rejects.toThrow('已失效'); - await expect(service.submitSection(first.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - await expect(service.submitGroup(first.id, 7, 'students')).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - await expect(service.submitAll(first.id, 7)).rejects.toMatchObject({ - message: expect.stringContaining('已失效'), - }); - }); - - it('不同会话的旧预览不会被其他会话的新预览过期', async () => { - const first = await service.createReview( - { ...baseArgs, assistantMessageId }, - { - title: 'A 会话预览', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '跨会话学生', phone: '13711110003' }], - issues: [], - }, - ], - }, - ); - - await service.expirePreviousReviews(7, 999, 'other-review'); - expect((await service.findOwned(first.id, 7)).status).toBe('pending'); - }); -}); diff --git a/apps/server/src/ai-config/ai-config.service.ts b/apps/server/src/ai-config/ai-config.service.ts index 4536948..1f3bd6a 100644 --- a/apps/server/src/ai-config/ai-config.service.ts +++ b/apps/server/src/ai-config/ai-config.service.ts @@ -78,8 +78,7 @@ export class AiConfigService implements AiConfigProbeContext { const code = isErrWithCode ? (err as Record).code : undefined; const errno = isErrWithCode ? (err as Record).errno : undefined; // MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062 - // SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT') - if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') { + if (code === 'ER_DUP_ENTRY' || errno === 1062) { const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } }); if (existing) return existing; } diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c90aca7..0cd513c 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -91,7 +91,6 @@ import { IntegrationConfigModule } from './integration/config/config.module'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => { - const dbType = config.get('DB_TYPE', 'sqlite'); const allEntities = [ Entities.Student, Entities.Room, @@ -151,26 +150,17 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.ImportStep, Entities.ImportRow, ]; - if (dbType === 'mysql') { - return { - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'dorm_billing'), - entities: allEntities, - migrations: allMigrations, - synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', - charset: 'utf8mb4', - }; - } return { - type: 'better-sqlite3' as const, - database: config.get('DB_DATABASE', 'dorm_billing.db'), - migrations: allMigrations, + type: 'mysql' as const, + host: config.get('DB_HOST', 'localhost'), + port: config.get('DB_PORT', 3306), + username: config.get('DB_USERNAME', 'root'), + password: config.get('DB_PASSWORD', ''), + database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, + migrations: allMigrations, synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', + charset: 'utf8mb4', }; }, }), diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index 632f6c2..5dafa4a 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -244,8 +244,8 @@ export class AttendanceLessonService { } catch (err: unknown) { const code = (err as Record).code; const errno = (err as Record).errno; - // MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT - if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') { + // MySQL: ER_DUP_ENTRY or errno 1062 + if (code === 'ER_DUP_ENTRY' || errno === 1062) { const existing = await sessionRepo.findOne({ where: { scheduleId, lessonDate }, }); diff --git a/apps/server/src/attendance/attendance-workflow.integration.spec.ts b/apps/server/src/attendance/attendance-workflow.integration.spec.ts deleted file mode 100644 index 9fb9de2..0000000 --- a/apps/server/src/attendance/attendance-workflow.integration.spec.ts +++ /dev/null @@ -1,332 +0,0 @@ -import type { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import request from 'supertest'; -import type { Repository } from 'typeorm'; -import { AppModule } from '../app.module'; -import type { DingTalkAttendanceResult } from '../integration/dingtalk.service'; -import { DingTalkService } from '../integration/dingtalk.service'; -import { - AttendanceRecord, - Organization, - Role, - Student, - StudentDingMapping, - User, -} from '../entities'; -import { createStudentImportTemplateWorkbook } from '../students/student-import'; - -const LESSON_DATE = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', -}).format(new Date()); -const STUDENT_A_DING_ID = 'integration-student-a'; -const STUDENT_B_DING_ID = 'integration-student-b'; - -const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); - -function chinaWeekDay(date: string): number { - const day = new Date(`${date}T00:00:00+08:00`).getDay(); - return day === 0 ? 7 : day; -} - -function attendanceResult( - userId: string, - checkId: string, - actualCheckTime: string, -): DingTalkAttendanceResult { - return { - userId, - userName: '', - workDate: LESSON_DATE, - timeResult: 'Normal', - locationResult: 'Normal', - planCheckTime: `${LESSON_DATE}T00:00:00+08:00`, - actualCheckTime, - checkId, - checkType: 'OnDuty', - sourceType: 'ATM', - deviceName: '集成测试考勤机', - deviceId: 'integration-device', - }; -} - -// Requires a fully configured attendance integration and is intentionally excluded from routine CI. -describe.skip('attendance workflow integration', () => { - let app: INestApplication; - let adminToken: string; - let teacherToken: string; - let mockedPunches: DingTalkAttendanceResult[]; - const originalEnv = { - DB_TYPE: process.env.DB_TYPE, - DB_DATABASE: process.env.DB_DATABASE, - DB_SYNCHRONIZE: process.env.DB_SYNCHRONIZE, - SEED_DEV: process.env.SEED_DEV, - ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, - }; - - beforeAll(async () => { - process.env.DB_TYPE = 'sqlite'; - process.env.DB_DATABASE = ':memory:'; - process.env.DB_SYNCHRONIZE = 'true'; - process.env.SEED_DEV = 'true'; - process.env.ADMIN_PASSWORD = 'admin123'; - - mockedPunches = []; - const dingTalk = { - fetchAttendanceResults: jest.fn(async () => mockedPunches), - }; - - const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(DingTalkService) - .useValue(dingTalk) - .compile(); - - app = moduleRef.createNestApplication(); - app.setGlobalPrefix('api'); - await app.init(); - - const login = await request(app.getHttpServer()) - .post('/api/auth/login') - .send({ username: 'admin', password: 'admin123' }) - .expect(201); - adminToken = login.body.access_token; - }); - - afterAll(async () => { - await app?.close(); - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it('imports students, builds a teacher class schedule, refreshes punches, and scopes reads', async () => { - const roleRepo = app.get>(getRepositoryToken(Role)); - const userRepo = app.get>(getRepositoryToken(User)); - const studentRepo = app.get>(getRepositoryToken(Student)); - const mappingRepo = app.get>( - getRepositoryToken(StudentDingMapping), - ); - const organizationRepo = app.get>(getRepositoryToken(Organization)); - const attendanceRepo = app.get>( - getRepositoryToken(AttendanceRecord), - ); - - const teacherRole = await roleRepo.findOneByOrFail({ code: 'teacher' }); - const teacherCreate = await request(app.getHttpServer()) - .post('/api/rbac/users') - .set(auth(adminToken)) - .send({ - username: 'integration-teacher', - password: 'teacher123', - name: '集成测试任课教师', - roleIds: [teacherRole.id], - }) - .expect(201); - expect(teacherCreate.body.message).toBe('用户创建成功'); - - const teacher = await userRepo.findOneByOrFail({ username: 'integration-teacher' }); - const teacherId = teacher.id; - const teacherLogin = await request(app.getHttpServer()) - .post('/api/auth/login') - .send({ username: 'integration-teacher', password: 'teacher123' }) - .expect(201); - teacherToken = teacherLogin.body.access_token; - - const host = await organizationRepo.findOneByOrFail({ isHost: true, status: 'active' }); - const workbook = createStudentImportTemplateWorkbook(); - const sheet = workbook.getWorksheet('学生基础+档案+录取')!; - sheet.spliceRows(2, 1); - sheet.addRow({ - phone: '13800000001', - name: '集成学生甲', - studentNo: 'IT001', - organization: host.name, - }); - sheet.addRow({ - phone: '13800000002', - name: '集成学生乙', - studentNo: 'IT002', - organization: host.name, - }); - const workbookBuffer = Buffer.from(await workbook.xlsx.writeBuffer()); - - const importResult = await request(app.getHttpServer()) - .post('/api/students/import') - .set(auth(adminToken)) - .attach('file', workbookBuffer, { - filename: 'attendance-workflow-students.xlsx', - contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }) - .expect(201); - expect(importResult.body).toMatchObject({ imported: 2, skipped: 0 }); - - const [studentA, studentB] = await Promise.all([ - studentRepo.findOneByOrFail({ phone: '13800000001' }), - studentRepo.findOneByOrFail({ phone: '13800000002' }), - ]); - await mappingRepo.save([ - mappingRepo.create({ dingUserId: STUDENT_A_DING_ID, studentId: studentA.id }), - mappingRepo.create({ dingUserId: STUDENT_B_DING_ID, studentId: studentB.id }), - ]); - - const classResult = await request(app.getHttpServer()) - .post('/api/classes') - .set(auth(adminToken)) - .send({ - name: '集成考勤班', - code: 'ATTENDANCE-INTEGRATION', - classType: 'culture', - status: 'active', - startDate: LESSON_DATE, - endDate: LESSON_DATE, - }) - .expect(201); - const classId = classResult.body.id; - - await request(app.getHttpServer()) - .post(`/api/classes/${classId}/students`) - .set(auth(adminToken)) - .send({ studentIds: [studentA.id, studentB.id] }) - .expect(201) - .expect(({ body }) => expect(body).toMatchObject({ added: 2, skipped: 0 })); - - await request(app.getHttpServer()) - .post(`/api/classes/${classId}/teachers`) - .set(auth(adminToken)) - .send({ userId: teacherId, roleType: 'subject_teacher', subject: '语文' }) - .expect(201); - - const classroomResult = await request(app.getHttpServer()) - .post('/api/classrooms') - .set(auth(adminToken)) - .send({ name: '集成测试教室', building: '测试楼', floor: 1, capacity: 30, roomType: '小' }) - .expect(201); - - const scheduleResult = await request(app.getHttpServer()) - .post('/api/class-schedules') - .set(auth(adminToken)) - .send({ - classId, - classroomId: classroomResult.body.id, - weekDay: chinaWeekDay(LESSON_DATE), - startTime: '00:00', - endTime: '23:59', - attendanceAdvanceMinutes: 0, - startDate: LESSON_DATE, - endDate: LESSON_DATE, - subject: '语文', - teacherId, - scheduleType: 'INTERNAL', - }) - .expect(201); - const scheduleId = scheduleResult.body.id; - - const initialPull = await request(app.getHttpServer()) - .post(`/api/attendance-lessons/schedules/${scheduleId}/pull`) - .set(auth(teacherToken)) - .send({ date: LESSON_DATE }) - .expect(201); - expect(initialPull.body.records).toHaveLength(2); - expect(initialPull.body.records.map((record: AttendanceRecord) => record.status)).toEqual([ - 'pending', - 'pending', - ]); - - const studentARecord = initialPull.body.records.find( - (record: AttendanceRecord) => record.studentId === studentA.id, - ); - await request(app.getHttpServer()) - .put(`/api/attendance-records/${studentARecord.id}`) - .set(auth(teacherToken)) - .send({ status: 'absent', remark: '教师本地覆盖' }) - .expect(200) - .expect(({ body }) => expect(body).toMatchObject({ status: 'absent', source: 'manual' })); - - mockedPunches = [ - attendanceResult(STUDENT_A_DING_ID, 'integration-check-a', `${LESSON_DATE}T01:00:00.000Z`), - attendanceResult(STUDENT_B_DING_ID, 'integration-check-b', `${LESSON_DATE}T01:05:00.000Z`), - ]; - - const refreshed = await request(app.getHttpServer()) - .post(`/api/attendance-lessons/schedules/${scheduleId}/pull`) - .set(auth(teacherToken)) - .send({ date: LESSON_DATE }) - .expect(201); - expect(refreshed.body.records).toHaveLength(2); - expect( - refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentA.id), - ).toMatchObject({ status: 'absent', source: 'manual', remark: '教师本地覆盖' }); - expect( - refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentB.id), - ).toMatchObject({ status: 'present', source: 'dingtalk', punchSource: 'ATM' }); - - const teacherRecords = await request(app.getHttpServer()) - .get( - `/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`, - ) - .set(auth(teacherToken)) - .expect(200); - expect(teacherRecords.body.list).toHaveLength(2); - const teacherView: Array> = - teacherRecords.body.list - .map((record: AttendanceRecord) => ({ - studentId: record.studentId, - status: record.status, - source: record.source, - })) - .sort((left, right) => left.studentId - right.studentId); - expect(teacherView).toEqual([ - { studentId: studentA.id, status: 'absent', source: 'manual' }, - { studentId: studentB.id, status: 'present', source: 'dingtalk' }, - ]); - - const adminRecords = await request(app.getHttpServer()) - .get( - `/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`, - ) - .set(auth(adminToken)) - .expect(200); - expect(adminRecords.body.list).toHaveLength(2); - expect( - adminRecords.body.list - .map((record: AttendanceRecord) => ({ - studentId: record.studentId, - status: record.status, - source: record.source, - })) - .sort( - (left: Pick, right: Pick) => - left.studentId - right.studentId, - ), - ).toEqual(teacherView); - - const unassignedClass = await request(app.getHttpServer()) - .post('/api/classes') - .set(auth(adminToken)) - .send({ - name: '未分配教师班级', - code: 'UNASSIGNED-INTEGRATION', - classType: 'culture', - status: 'active', - }) - .expect(201); - await request(app.getHttpServer()) - .get(`/api/attendance-records?classId=${unassignedClass.body.id}`) - .set(auth(teacherToken)) - .expect(400); - - const persisted = await attendanceRepo.find({ - where: { classId }, - order: { studentId: 'ASC' }, - }); - expect(persisted).toHaveLength(2); - expect(persisted).toEqual([ - expect.objectContaining({ studentId: studentA.id, status: 'absent', source: 'manual' }), - expect.objectContaining({ studentId: studentB.id, status: 'present', source: 'dingtalk' }), - ]); - }); -}); diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index ecc8887..4509b9d 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -577,9 +577,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { }); // Simulate unique constraint on save sessionRepo.save.mockRejectedValueOnce( - Object.assign(new Error('UNIQUE constraint failed'), { - code: 'SQLITE_CONSTRAINT', - errno: undefined, + Object.assign(new Error('Duplicate entry'), { + code: 'ER_DUP_ENTRY', + errno: 1062, }), ); classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); diff --git a/apps/server/src/database/attendance-fk-restrict.spec.ts b/apps/server/src/database/attendance-fk-restrict.spec.ts deleted file mode 100644 index 81f915d..0000000 --- a/apps/server/src/database/attendance-fk-restrict.spec.ts +++ /dev/null @@ -1,380 +0,0 @@ -import Database from 'better-sqlite3'; -type SqliteDB = InstanceType; - -/** - * Real SQLite foreign-key constraint tests. - * - * These tests use the `better-sqlite3` driver directly (in-memory) to verify - * that ON DELETE RESTRICT is enforced at the database level, not just in - * application-layer guards. - */ -describe('attendance_sessions FK RESTRICT — real SQLite', () => { - let db: SqliteDB; - - function createSchema(): void { - db.exec('PRAGMA foreign_keys = ON'); - db.exec(` - CREATE TABLE IF NOT EXISTS classes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - is_archived INTEGER DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS class_schedule ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class_id INTEGER, - week_day INTEGER NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status TEXT DEFAULT 'in_progress', - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - } - - beforeEach(() => { - db = new Database(':memory:'); - createSchema(); - }); - - afterEach(() => { - db.close(); - }); - - it('blocks class deletion when attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).toThrow(); - }); - - it('allows class deletion when no attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).not.toThrow(); - - const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as { - cnt: number; - }; - expect(remaining.cnt).toBe(0); - }); - - it('blocks schedule deletion when attendance sessions reference it', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - expect(() => { - db.exec('DELETE FROM class_schedule WHERE id = 1'); - }).toThrow(); - }); - - it('PRAGMA foreign_key_list confirms both FKs are present', () => { - // Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks - const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{ - id: number; - seq: number; - table: string; - from: string; - to: string; - on_update: string; - on_delete: string; - match: string; - }>; - - expect(rows.length).toBe(2); - - const scheduleFk = rows.find((fk) => fk.from === 'schedule_id'); - expect(scheduleFk).toBeDefined(); - expect(scheduleFk!.table).toBe('class_schedule'); - expect(scheduleFk!.on_delete).toBe('RESTRICT'); - - const classFk = rows.find((fk) => fk.from === 'class_id'); - expect(classFk).toBeDefined(); - expect(classFk!.table).toBe('classes'); - expect(classFk!.on_delete).toBe('RESTRICT'); - }); - - it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - - // Verify the session exists - const session = db - .prepare('SELECT * FROM attendance_sessions WHERE class_id = 1') - .get() as Record; - expect(session).toBeDefined(); - - // Delete should fail - expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow(); - - // Session should still exist after failed delete - const after = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1') - .get() as { cnt: number }; - expect(after.cnt).toBe(1); - }); -}); - -/** - * Integration test: simulate the protectAttendanceHistory SQLite migration. - * - * Creates tables WITHOUT foreign keys (pre-migration state), inserts parent - * session and child attendance_record, runs the table-rebuild migration - * (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON, - * foreign_key_check), then verifies: - * - attendance_record.attendance_session_id is preserved - * - RESTRICT still blocks class/schedule deletion - */ -describe('protectAttendanceHistory SQLite migration — integration', () => { - let db: SqliteDB; - - function createPreMigrationSchema(): void { - // Schema WITHOUT foreign keys on attendance_sessions (pre-migration) - db.exec('PRAGMA foreign_keys = ON'); - db.exec(` - CREATE TABLE IF NOT EXISTS classes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - is_archived INTEGER DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS class_schedule ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class_id INTEGER, - week_day INTEGER NOT NULL - ) - `); - // attendance_sessions WITHOUT foreign keys - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status TEXT DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - // Legacy columns came first; course-attendance columns were appended later. - db.exec(` - CREATE TABLE IF NOT EXISTS attendance_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - schedule_id INTEGER, - attendance_session_id INTEGER - ) - `); - } - - function runMigration(): void { - // Step 1: PRAGMA foreign_keys = OFF outside transaction - db.exec('PRAGMA foreign_keys = OFF'); - try { - db.exec('BEGIN'); - try { - // Rebuild attendance_sessions with FKs - db.exec(` - CREATE TABLE attendance_sessions_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - schedule_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - lesson_date DATE NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'in_progress', - started_by INTEGER, - started_at DATETIME, - completed_by INTEGER, - completed_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT - ) - `); - db.exec( - 'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions', - ); - db.exec('DROP TABLE attendance_sessions'); - db.exec( - 'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions', - ); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - - // Rebuild attendance_records with FK on attendance_session_id - const recordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all() as Array<{ from: string }>; - const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id'); - if (!hasSessionFk) { - db.exec(` - CREATE TABLE attendance_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - schedule_id INTEGER, - attendance_session_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL - ) - `); - db.exec(` - INSERT INTO attendance_records_new ( - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - ) - SELECT - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - FROM attendance_records - `); - db.exec('DROP TABLE attendance_records'); - db.exec( - 'ALTER TABLE attendance_records_new RENAME TO attendance_records', - ); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } - - db.exec('COMMIT'); - } catch (err) { - db.exec('ROLLBACK'); - throw err; - } - } finally { - db.exec('PRAGMA foreign_keys = ON'); - } - - // Run foreign_key_check — should be clean - const checkRows = db.prepare('PRAGMA foreign_key_check').all(); - if (checkRows.length > 0) { - throw new Error( - `外键一致性检查失败: ${checkRows.length} 行违反外键约束`, - ); - } - } - - beforeEach(() => { - db = new Database(':memory:'); - createPreMigrationSchema(); - }); - - afterEach(() => { - db.close(); - }); - - it('preserves attendance_record.session_id after migration', () => { - db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')"); - db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)"); - db.exec( - "INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')", - ); - db.exec( - "INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')", - ); - - // Verify pre-migration state - const preSessionFk = db - .prepare("PRAGMA foreign_key_list('attendance_sessions')") - .all(); - expect(preSessionFk.length).toBe(0); - - const preRecordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all(); - expect(preRecordsFk.length).toBe(0); - - // Run migration - runMigration(); - - // Verify attendance_record still has correct attendance_session_id - const record = db - .prepare('SELECT * FROM attendance_records WHERE id = 1') - .get() as Record; - expect(record).toBeDefined(); - expect(record.attendance_session_id).toBe(1); - expect(record.attendance_date).toBe('2026-01-01'); - expect(record.session).toBe('morning'); - expect(record.status).toBe('present'); - - // Verify FKs now exist on both tables - const postSessionFk = db - .prepare("PRAGMA foreign_key_list('attendance_sessions')") - .all(); - expect(postSessionFk.length).toBe(2); - - const postRecordsFk = db - .prepare("PRAGMA foreign_key_list('attendance_records')") - .all() as Array<{ from: string; table: string; on_delete: string }>; - const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id'); - expect(sessionFk).toBeDefined(); - expect(sessionFk!.table).toBe('attendance_sessions'); - expect(sessionFk!.on_delete).toBe('SET NULL'); - - // RESTRICT still blocks class/schedule deletion - expect(() => { - db.exec('DELETE FROM classes WHERE id = 1'); - }).toThrow(); - expect(() => { - db.exec('DELETE FROM class_schedule WHERE id = 1'); - }).toThrow(); - - // Verify data survived the failed deletes - const sessionAfter = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1') - .get() as { cnt: number }; - expect(sessionAfter.cnt).toBe(1); - - const recordAfter = db - .prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1') - .get() as { cnt: number }; - expect(recordAfter.cnt).toBe(1); - - const classAfter = db - .prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1') - .get() as { cnt: number }; - expect(classAfter.cnt).toBe(1); - }); -}); diff --git a/apps/server/src/database/database-migrations.ai.ts b/apps/server/src/database/database-migrations.ai.ts index 77d77a8..d49a403 100644 --- a/apps/server/src/database/database-migrations.ai.ts +++ b/apps/server/src/database/database-migrations.ai.ts @@ -8,14 +8,11 @@ export async function ensureAiConfigTable( ): Promise { await withQueryRunner(dataSource, async (runner) => { const tables = await runner.getTables(['ai_config']); - const isMySQL = dataSource.options.type === 'mysql'; if (tables.length === 0) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN'; - const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP'; + const pkDef = 'id INTEGER PRIMARY KEY AUTO_INCREMENT'; + const boolType = 'TINYINT(1)'; + const datetimeFn = 'CURRENT_TIMESTAMP'; await runner.query(` CREATE TABLE ai_config ( @@ -38,18 +35,12 @@ export async function ensureAiConfigTable( ) `); - if (isMySQL) { - try { - await runner.query( - 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', - ); - } catch { - // Index may already exist; MySQL has no IF NOT EXISTS for indexes - } - } else { + try { await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)', + 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', ); + } catch { + // Index may already exist; MySQL has no IF NOT EXISTS for indexes } logger.log('已创建 ai_config 表'); @@ -67,10 +58,10 @@ export async function ensureAiConfigTable( { name: 'api_key_auth_tag', def: 'VARCHAR(50)' }, { name: 'key_last4', def: 'VARCHAR(4)' }, { name: 'default_model', def: 'VARCHAR(100)' }, - { name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'enabled', def: 'TINYINT(1) DEFAULT 0' }, { name: 'timeout_ms', def: 'INT DEFAULT 30000' }, { name: 'reasoning_effort', def: 'VARCHAR(20)' }, - { name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, + { name: 'verified', def: 'TINYINT(1) DEFAULT 0' }, { name: 'last_tested_at', def: 'DATETIME' }, { name: 'last_test_latency_ms', def: 'INT' }, { name: 'created_at', def: 'DATETIME' }, diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index f76a7b2..b68f9fd 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -33,13 +33,11 @@ export async function ensureCourseAttendanceSchema( 'attendance_sessions', ]); const tableNames = new Set(tables.map((table) => table.name)); - const isMySQL = dataSource.options.type === 'mysql'; if (!tableNames.has('attendance_sessions')) { - const pkDef = isMySQL - ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' - : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; - await runner.query(attendanceSessionsDdl('attendance_sessions', pkDef)); + await runner.query( + attendanceSessionsDdl('attendance_sessions', 'id INTEGER PRIMARY KEY AUTO_INCREMENT'), + ); } if (tableNames.has('class_schedule')) { @@ -72,14 +70,10 @@ export async function ensureCourseAttendanceSchema( } }; await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', + 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', ); await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', + 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', ); }); } @@ -92,12 +86,7 @@ export async function protectAttendanceHistory( const tables = await runner.getTables(['attendance_sessions']); if (tables.length === 0) return; - const isMySQL = dataSource.options.type === 'mysql'; - if (isMySQL) { - await migrateMySQLAttendanceFKs(runner, logger); - } else { - await migrateSQLiteAttendanceFKs(runner, logger); - } + await migrateMySQLAttendanceFKs(runner, logger); }); } @@ -164,97 +153,3 @@ export async function migrateMySQLAttendanceFKs( logger.log(`已添加考勤场次删除保护约束: ${c.name}`); } } - -export async function migrateSQLiteAttendanceFKs( - runner: QueryRunner, - logger: Logger, -): Promise { - // SQLite cannot ALTER TABLE to add foreign keys. - // Rebuild the table inside a transaction: create a new table with FK constraints, - // copy all rows, drop old, rename new, then recreate indexes. - const fkRows: Array<{ id: number }> = await runner.query( - "PRAGMA foreign_key_list('attendance_sessions')", - ); - if (fkRows.length > 0) return; // FKs already present - - logger.log('正在重建 attendance_sessions 表以添加外键保护…'); - - // PRAGMA foreign_keys=OFF must be issued outside the transaction - await runner.query('PRAGMA foreign_keys = OFF'); - try { - await runner.query('BEGIN'); - try { - await runner.query(attendanceSessionsDdl('attendance_sessions_new', 'id INTEGER PRIMARY KEY AUTOINCREMENT')); - await runner.query(` - INSERT INTO attendance_sessions_new ( - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - ) - SELECT - id, schedule_id, class_id, lesson_date, status, - started_by, started_at, completed_by, completed_at, created_at, updated_at - FROM attendance_sessions - `); - await runner.query('DROP TABLE attendance_sessions'); - await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', - ); - - // Rebuild attendance_records to add/protect FK on attendance_session_id - const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); - const hasSessionFk = recordsFk.some( - (r: { from: string }) => r.from === 'attendance_session_id', - ); - if (!hasSessionFk) { - await runner.query(` - CREATE TABLE attendance_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - class_id INTEGER, - schedule_id INTEGER, - attendance_session_id INTEGER, - attendance_date DATE NOT NULL, - session VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - remark VARCHAR(200), - source VARCHAR(20) DEFAULT 'manual', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL - ) - `); - await runner.query(` - INSERT INTO attendance_records_new ( - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - ) - SELECT - id, student_id, class_id, schedule_id, attendance_session_id, - attendance_date, session, status, remark, source, created_at, updated_at - FROM attendance_records - `); - await runner.query('DROP TABLE attendance_records'); - await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); - await runner.query( - 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', - ); - } - - // Verify foreign key integrity BEFORE committing the transaction. - // If violations exist, the transaction rolls back and old tables are preserved. - const checkRows = await runner.query('PRAGMA foreign_key_check'); - if (checkRows.length > 0) { - throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); - } - - await runner.query('COMMIT'); - logger.log('attendance_sessions 表外键保护重建完成'); - } catch (err) { - await runner.query('ROLLBACK'); - throw err; - } - } finally { - await runner.query('PRAGMA foreign_keys = ON'); - } -} diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index d5fbf67..be47e00 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -124,7 +124,6 @@ export async function normalizeClassDates( dataSource: DataSource, logger: Logger, ): Promise { - const driver = dataSource.options.type; let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; await withQueryRunner(dataSource, async (runner) => { @@ -135,27 +134,21 @@ export async function normalizeClassDates( // This cleanup is only for legacy schemas that stored dates as strings; // comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE // in strict SQL mode. - if (driver === 'mysql') { - columns = columns.filter((columnName) => { - const column = table.columns.find((item) => item.name === columnName); - const type = String(column?.type ?? '').toLowerCase(); - return !['date', 'datetime', 'timestamp'].includes(type); - }); - if (columns.length === 0) return; - } + columns = columns.filter((columnName) => { + const column = table.columns.find((item) => item.name === columnName); + const type = String(column?.type ?? '').toLowerCase(); + return !['date', 'datetime', 'timestamp'].includes(type); + }); + if (columns.length === 0) return; }); - const columnText = (column: string) => - driver === 'mysql' ? `CAST(${column} AS CHAR)` : column; - const firstTenChars = (column: string) => - driver === 'mysql' - ? `NULLIF(LEFT(${columnText(column)}, 10), '')` - : `NULLIF(substr(${column}, 1, 10), '')`; + const columnText = (column: string) => `CAST(${column} AS CHAR)`; + const firstTenChars = (column: string) => `NULLIF(LEFT(${columnText(column)}, 10), '')`; const normalizedDate = (column: string) => `CASE WHEN ${column} IS NULL THEN NULL ELSE ${firstTenChars(column)} END`; - const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; + const lengthFunction = 'CHAR_LENGTH'; const needsNormalization = (column: string) => `( ${column} IS NOT NULL AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10) diff --git a/apps/server/src/database/database-migrations.class-student.spec.ts b/apps/server/src/database/database-migrations.class-student.spec.ts index 6088762..b07946f 100644 --- a/apps/server/src/database/database-migrations.class-student.spec.ts +++ b/apps/server/src/database/database-migrations.class-student.spec.ts @@ -22,7 +22,7 @@ async function createService(runner: ReturnType) { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.classroom-status.spec.ts b/apps/server/src/database/database-migrations.classroom-status.spec.ts index aaf079e..3702eaf 100644 --- a/apps/server/src/database/database-migrations.classroom-status.spec.ts +++ b/apps/server/src/database/database-migrations.classroom-status.spec.ts @@ -16,7 +16,7 @@ describe('DatabaseMigrationsService — classroom status normalization', () => { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.deposit-refund.spec.ts b/apps/server/src/database/database-migrations.deposit-refund.spec.ts index 1b1ac7c..376de04 100644 --- a/apps/server/src/database/database-migrations.deposit-refund.spec.ts +++ b/apps/server/src/database/database-migrations.deposit-refund.spec.ts @@ -24,7 +24,7 @@ async function createService(runner: ReturnType) { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.room-gender.spec.ts b/apps/server/src/database/database-migrations.room-gender.spec.ts index 3728e3c..12fad00 100644 --- a/apps/server/src/database/database-migrations.room-gender.spec.ts +++ b/apps/server/src/database/database-migrations.room-gender.spec.ts @@ -20,7 +20,7 @@ describe('DatabaseMigrationsService — room gender cleanup', () => { { provide: getDataSourceToken(), useValue: { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue(runner), }, }, diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts index fd8d271..09e9929 100644 --- a/apps/server/src/database/database-migrations.schema.ts +++ b/apps/server/src/database/database-migrations.schema.ts @@ -38,8 +38,7 @@ export async function ensureAttendanceDevicesSchema( dataSource: DataSource, ): Promise { await withQueryRunner(dataSource, async (runner) => { - const isMySQL = dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices ( id ${pk}, device_sn VARCHAR(100) NOT NULL, @@ -77,15 +76,11 @@ export async function ensureAttendanceDevicesSchema( const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique); if (!uniqueSn) { await createIndex( - isMySQL - ? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)', + 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)', ); } await createIndex( - isMySQL - ? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)' - : 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', + 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', ); }); } @@ -94,8 +89,7 @@ export async function ensureStudentWalletSchema( dataSource: DataSource, ): Promise { await withQueryRunner(dataSource, async (runner) => { - const isMySQL = dataSource.options.type === 'mysql'; - const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets ( id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -140,9 +134,9 @@ export async function ensureStudentWalletSchema( const hasImportKey = refreshedRoomExpenses?.indices.some((index) => index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); if (!hasImportKey) { - await runner.query(isMySQL - ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' - : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); + await runner.query( + 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)', + ); } } const bills = await runner.getTable('bills'); diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index 55a3b5d..c06b78c 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -40,7 +40,7 @@ function mockRunner( return { release, connect, query, getTables, getTable } satisfies MockRunner; } -function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') { +function createDataSource(runner: MockRunner, dbType: string = 'mysql') { return { options: { type: dbType }, createQueryRunner: jest.fn().mockReturnValue(runner), @@ -81,7 +81,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { expect(runner.connect).toHaveBeenCalled(); expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config')); expect(runner.query).toHaveBeenCalledWith( - expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'), + expect.stringContaining('CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)'), ); expect(runner.release).toHaveBeenCalled(); }); @@ -260,7 +260,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => { describe('DatabaseMigrationsService — protectAttendanceHistory', () => { let service: MigrationsPrivate & DatabaseMigrationsService; - async function bootstrap(runner: MockRunner, dbType: string = 'better-sqlite3') { + async function bootstrap(runner: MockRunner, dbType: string = 'mysql') { const dataSource = createDataSource(runner, dbType); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -279,107 +279,6 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { expect(runner.release).toHaveBeenCalled(); }); - it('SQLite: exits early when FKs already exist', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows - await bootstrap(runner); - await service.protectAttendanceHistory(); - - // Should not run any TABLE creation (rebuild) - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0); - expect(runner.release).toHaveBeenCalled(); - }); - - it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - // PRAGMA foreign_key_list for attendance_sessions → empty - runner.query.mockResolvedValueOnce([]); - // PRAGMA foreign_key_list for attendance_records → also empty (no FK yet) - runner.query.mockResolvedValueOnce([]); - await bootstrap(runner); - await service.protectAttendanceHistory(); - - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - - // PRAGMA foreign_keys = OFF outside the transaction - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true); - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe( - true, - ); - expect( - queries.some((q: string) => - q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'), - ), - ).toBe(true); - expect( - queries.some((q: string) => - q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'), - ), - ).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe( - true, - ); - // attendance_records rebuilt with FK - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe( - true, - ); - expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true); - // PRAGMA foreign_keys restored to ON and foreign_key_check runs - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true); - expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true); - expect(runner.release).toHaveBeenCalled(); - }); - - it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => { - const runner = mockRunner({ - getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], - }); - // Use mockImplementation to match by SQL content, not call position - runner.query.mockImplementation((sql: string) => { - if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) { - return Promise.resolve([]); // FKs absent → trigger rebuild - } - if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) { - return Promise.resolve([ - { table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 }, - ]); - } - return Promise.resolve([]); - }); - await bootstrap(runner); - - await expect(service.protectAttendanceHistory()).rejects.toThrow(/外键一致性检查失败/); - - const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : '', - ); - - // The transaction should have been rolled back (ROLLBACK called) - expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true); - // COMMIT should NOT have been called - expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false); - // PRAGMA foreign_keys should still be restored - expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true); - expect(runner.release).toHaveBeenCalled(); - }); it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => { const runner = mockRunner({ getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index 4e9da94..f59f649 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -14,12 +14,7 @@ import { config } from 'dotenv'; config(); -const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql'; - export async function runMigrationsOnStartup(): Promise { - // 该迁移由 MySQL 生成;SQLite 开发环境由 AppModule 中的 TypeORM synchronize 建表。 - if (!isMySQL) return; - const ds = new DataSource({ type: 'mysql', host: process.env.DB_HOST || 'localhost', diff --git a/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts b/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts index caeac05..561e962 100644 --- a/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts +++ b/apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts @@ -12,10 +12,7 @@ export class EnlargeAiReviewSections1784900000000 implements MigrationInterface const column = table?.columns.find((item) => item.name === 'sections_json'); const columnType = String(column?.type ?? '').toLowerCase(); if (columnType === 'longtext') return; - if (queryRunner.connection.options.type === 'mysql') { - await queryRunner.query('ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT'); - } - // SQLite TEXT 无长度上限,无需变更。 + await queryRunner.query('ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT'); } async down(_queryRunner: QueryRunner): Promise { diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts index 92fc1f2..5298a1c 100644 --- a/apps/server/src/occupancies/occupancies.boundaries.spec.ts +++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts @@ -30,7 +30,7 @@ function createQueryBuilderMock(result: T | null): QueryBuilderMock { function createTransactionDataSource(manager: Record): DataSource { return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, transaction: jest.fn( async (fn: (manager: Record) => unknown) => fn(manager), ), @@ -105,7 +105,7 @@ function createQueryRunnerDataSource(config: { }; return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, createQueryRunner: jest.fn().mockReturnValue({ connect: jest.fn().mockResolvedValue(undefined), startTransaction: jest.fn().mockResolvedValue(undefined), diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 372c435..d5254fd 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -31,7 +31,7 @@ function createQueryBuilderMock(result: T | null): QueryBuilderMock { function createTransactionDataSource(manager: Record): DataSource { return { - options: { type: 'sqlite' }, + options: { type: 'mysql' }, transaction: jest.fn(async (fn: (manager: Record) => unknown) => fn(manager)), } as any as DataSource; } diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 6ba2d8e..96717a2 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -163,11 +163,7 @@ export class OccupanciesService { private withPessimisticWriteLock( qb: SelectQueryBuilder, ): SelectQueryBuilder { - const type = this.dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); - } - return qb; + return qb.setLock('pessimistic_write'); } private normalizePositiveMoney(value: number, label: string): number { diff --git a/apps/server/src/occupancies/occupancy-lock.ts b/apps/server/src/occupancies/occupancy-lock.ts index 3e7a16d..c47cd7f 100644 --- a/apps/server/src/occupancies/occupancy-lock.ts +++ b/apps/server/src/occupancies/occupancy-lock.ts @@ -2,11 +2,7 @@ import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm'; export function withPessimisticWriteLock( qb: SelectQueryBuilder, - dataSource: DataSource, + _dataSource: DataSource, ): SelectQueryBuilder { - const type = dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); - } - return qb; + return qb.setLock('pessimistic_write'); } diff --git a/apps/server/src/rooms/room-inspections.service.spec.ts b/apps/server/src/rooms/room-inspections.service.spec.ts index b6d6c82..be420b2 100644 --- a/apps/server/src/rooms/room-inspections.service.spec.ts +++ b/apps/server/src/rooms/room-inspections.service.spec.ts @@ -70,7 +70,7 @@ describe('RoomInspectionsService', () => { }), } as unknown as EntityManager; const dataSource = { - options: { type: 'better-sqlite3' }, + options: { type: 'mysql' }, manager, transaction: jest.fn(async (callback) => callback(manager)), } as unknown as DataSource; diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts index 7bf4d5f..6b8d476 100644 --- a/apps/server/src/rooms/room-inspections.service.ts +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -206,9 +206,7 @@ export class RoomInspectionsService implements OnApplicationBootstrap { allowArchived: boolean, ): Promise { let query = manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId }); - if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { - query = query.setLock('pessimistic_write'); - } + query = query.setLock('pessimistic_write'); const room = await query.getOne(); if (!room) throw new BadRequestException('宿舍不存在'); if (!allowArchived && room.status === 'archived') { diff --git a/apps/server/src/wallets/wallets.service.spec.ts b/apps/server/src/wallets/wallets.service.spec.ts index adf919f..740aac2 100644 --- a/apps/server/src/wallets/wallets.service.spec.ts +++ b/apps/server/src/wallets/wallets.service.spec.ts @@ -138,22 +138,6 @@ describe('WalletsService wallet locking', () => { }; }; - it('skips pessimistic locking for SQLite', async () => { - const ctx = createQueryManager(); - const service = new WalletsService( - {} as any, - {} as any, - {} as any, - { options: { type: 'better-sqlite3' } } as any, - ); - - const result = await (service as any).getOrCreateWallet(ctx.manager, 10, true); - - expect(result).toBe(ctx.wallet); - expect(ctx.query.setLock).not.toHaveBeenCalled(); - expect(ctx.query.getOne).toHaveBeenCalled(); - }); - it('keeps pessimistic write locking for MySQL', async () => { const ctx = createQueryManager(); const service = new WalletsService( diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 62a078e..009ae64 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -284,9 +284,7 @@ export class WalletsService { let query = manager .createQueryBuilder(StudentWallet, 'wallet') .where('wallet.studentId = :studentId', { studentId }); - if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { - query = query.setLock('pessimistic_write'); - } + query = query.setLock('pessimistic_write'); return query.getOne(); }; let wallet = await find(); diff --git a/package-lock.json b/package-lock.json index d061824..9bc190e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,7 +132,6 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -148,9 +147,6 @@ "tsconfig-paths": "^4.2.0", "typescript": "~6.0.2", "typescript-eslint": "^8.20.0" - }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0" } }, "node_modules/@angular-devkit/core": { @@ -5865,16 +5861,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -8290,6 +8276,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -8326,6 +8313,7 @@ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -8745,7 +8733,8 @@ "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/chrome-trace-event": { "version": "1.0.4", @@ -9923,6 +9912,7 @@ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -9953,6 +9943,7 @@ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -10853,6 +10844,7 @@ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -11049,7 +11041,8 @@ "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/finalhandler": { "version": "2.1.1", @@ -11531,7 +11524,8 @@ "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/glob": { "version": "13.0.6", @@ -12042,7 +12036,8 @@ "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/inline-style-parser": { "version": "0.2.7", @@ -14640,6 +14635,7 @@ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -14869,7 +14865,8 @@ "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/mrmime": { "version": "2.0.1", @@ -15017,7 +15014,8 @@ "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/napi-postinstall": { "version": "0.3.4", @@ -15079,6 +15077,7 @@ "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -15092,6 +15091,7 @@ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -15973,6 +15973,7 @@ "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -16102,6 +16103,7 @@ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -16190,6 +16192,7 @@ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "optional": true, + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -16220,6 +16223,7 @@ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17026,7 +17030,8 @@ } ], "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -17048,6 +17053,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -17555,6 +17561,7 @@ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -18216,6 +18223,7 @@ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, diff --git a/package.json b/package.json index 1ec0d36..be80561 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "turbo run test", "format": "turbo run format", "typecheck": "turbo run typecheck", - "clean": "rimraf apps/server/dist apps/admin/dist apps/server/dorm_billing.db node_modules apps/*/node_modules packages/*/node_modules" + "clean": "rimraf apps/server/dist apps/admin/dist node_modules apps/*/node_modules packages/*/node_modules" }, "devDependencies": { "oxfmt": "^0.57.0", diff --git a/技术文档.md b/技术文档.md index f2ea5f0..8d61ff7 100644 --- a/技术文档.md +++ b/技术文档.md @@ -26,10 +26,10 @@ ``` 前端 (React + Vite) 后端 (NestJS) 数据库 ┌─────────────────┐ ┌──────────────────┐ ┌──────────┐ -│ React 19 │ │ NestJS 11 │ │ SQLite │ -│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ (开发) │ -│ ECharts │ │ JWT + Passport │ │ MySQL 8 │ -│ Vite 8 │ │ ExcelJS + PDFKit │ │ (生产) │ +│ React 19 │ │ NestJS 11 │ │ MySQL 8 │ +│ Ant Design 6 │────▶│ TypeORM 0.3 │────▶│ │ +│ ECharts │ │ JWT + Passport │ │ │ +│ Vite 8 │ │ ExcelJS + PDFKit │ │ │ └─────────────────┘ └──────────────────┘ └──────────┘ ``` @@ -51,7 +51,7 @@ - class-validator(参数校验) - ExcelJS(Excel 导出) - PDFKit(PDF 导出) -- SQLite / MySQL(双数据库支持) +- MySQL 8(唯一支持的数据库) --- @@ -199,7 +199,7 @@ | 变量 | 说明 | 默认值 | |------|------|--------| -| DB_TYPE | 数据库类型 | sqlite | +| DB_TYPE | 数据库类型(仅支持 MySQL) | mysql | | DB_HOST | MySQL 主机 | localhost | | DB_PORT | MySQL 端口 | 3306 | | DB_USERNAME | 数据库用户 | root | From 1e1e24f0f08c4c93d19263fe13ed21e27ce718ae Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:46:11 +0800 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E6=8E=A7=E5=88=B6=E5=99=A8=20DI=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E4=B8=8E=E7=8F=AD=E7=BA=A7=E6=97=A5=E6=9C=9F=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=A9=BA=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/attendance/attendance-records.controller.ts | 6 ++++-- apps/server/src/database/database-migrations.backfill.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index 68f16f2..ceca7dd 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common'; import type { Response } from 'express'; import { AttendanceControllerBase, RequestUser } from './attendance.controller-base'; +import { AttendanceService } from './attendance.service'; +import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; @@ -23,8 +25,8 @@ import * as ExcelJS from 'exceljs'; @Controller() export class AttendanceRecordsController extends AttendanceControllerBase { constructor( - service: import('./attendance.service').AttendanceService, - importService: import('./attendance-import.service').AttendanceImportService, + service: AttendanceService, + importService: AttendanceImportService, logService: OperationLogsService, authz: AuthorizationService, ) { diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index be47e00..b48f606 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -139,8 +139,8 @@ export async function normalizeClassDates( const type = String(column?.type ?? '').toLowerCase(); return !['date', 'datetime', 'timestamp'].includes(type); }); - if (columns.length === 0) return; }); + if (columns.length === 0) return; const columnText = (column: string) => `CAST(${column} AS CHAR)`; const firstTenChars = (column: string) => `NULLIF(LEFT(${columnText(column)}, 10), '')`; From 2a519a371420a21f5942774817ed7723fb444443 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:53:44 +0800 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20=E4=BB=AA=E8=A1=A8=E7=9B=98=20tota?= =?UTF-8?q?lCapacity=20=E8=81=9A=E5=90=88=E5=80=BC=E8=BD=AC=E4=B8=BA?= =?UTF-8?q?=E6=95=B0=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/dashboard/dashboard.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index dbaae9b..b7309aa 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -101,7 +101,8 @@ export class DashboardService { .select('SUM(r.capacity)', 'total') .where('r.status != :archived', { archived: 'archived' }); const totalCapacity = await capQb.getRawOne(); - const cap = totalCapacity?.total || 0; + // MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number + const cap = Number(totalCapacity?.total ?? 0) || 0; const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; const billStatsQb = this.billRepo From 32343b271b7f86d158d0841a66a75564184470e4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:00:45 +0800 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20=E9=80=9A=E7=9F=A5=20SSE=20?= =?UTF-8?q?=E9=95=BF=E8=BF=9E=E6=8E=A5=E8=A2=AB=20Nginx=2060s=20=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E6=8E=90=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/nginx.conf | 16 ++++++++++ .../admin/src/components/NotificationBell.tsx | 3 +- .../notifications/notifications.controller.ts | 29 +++++++++++-------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf index ce1d6dd..ebaef43 100644 --- a/apps/admin/nginx.conf +++ b/apps/admin/nginx.conf @@ -25,6 +25,22 @@ server { add_header Cache-Control "no-cache"; } + # SSE 长连接:禁用代理缓冲并放宽读写超时,避免 60s 空闲被掐断 + location ~ ^/api/(notifications/stream|attendance-records/import/dingtalk/stream|ai/chat/.*/stream)$ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no; + } + location /api/ { proxy_pass http://backend:3000/api/; proxy_set_header Host $host; diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index 3f194bc..3dd59f3 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -58,6 +58,7 @@ const NotificationBell: React.FC = () => { const token = useUserStore.getState().token; if (!token) return; const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); + es.onopen = () => setSseDown(false); es.onmessage = (event) => { try { JSON.parse(event.data); @@ -68,7 +69,7 @@ const NotificationBell: React.FC = () => { } }; es.onerror = () => { - es.close(); + // 不主动关闭:EventSource 会自动重连,主动关闭会导致一次超时后实时通知永久断流 setSseDown(true); }; return () => { diff --git a/apps/server/src/notifications/notifications.controller.ts b/apps/server/src/notifications/notifications.controller.ts index c1189a4..d285482 100644 --- a/apps/server/src/notifications/notifications.controller.ts +++ b/apps/server/src/notifications/notifications.controller.ts @@ -9,7 +9,7 @@ import { UseGuards, } from '@nestjs/common'; import { Request } from 'express'; -import { Observable, map } from 'rxjs'; +import { Observable, interval, map, merge } from 'rxjs'; import { NotificationsService } from './notifications.service'; import { NotificationQueryDto } from './dto/notification.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -53,17 +53,22 @@ export class NotificationsController { req.on('close', () => { this.service.unsubscribe(userId); }); - return this.service.subscribe(userId).pipe( - map((notification) => ({ - data: JSON.stringify({ - id: notification.id, - type: notification.type, - title: notification.title, - content: notification.content, - link: notification.link, - createdAt: notification.createdAt, - }), - } as MessageEvent)), + // 每 25s 发送一次空消息作为心跳,避免空闲连接被 Nginx 等中间层超时掐断。 + // 空 data 会被前端 EventSource 收到并忽略(JSON.parse 失败)。 + return merge( + this.service.subscribe(userId).pipe( + map((notification) => ({ + data: JSON.stringify({ + id: notification.id, + type: notification.type, + title: notification.title, + content: notification.content, + link: notification.link, + createdAt: notification.createdAt, + }), + } as MessageEvent)), + ), + interval(25_000).pipe(map(() => ({ data: '' } as MessageEvent))), ); } From c8316e9e8e350ced456342d7ac2a0c3500ff365a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:43:02 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20=E7=BB=93=E7=AE=97=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=92=89=E9=92=89=E5=B7=B2=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E8=AF=B7=E5=81=87=E5=B9=B6=E6=A0=87=E8=AE=B0=E4=B8=BA=E8=AF=B7?= =?UTF-8?q?=E5=81=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 1 + .../attendance-import.service.spec.ts | 73 +++++++- .../attendance/attendance-import.service.ts | 134 ++++++++++++- .../attendance/attendance-lesson.service.ts | 176 ++++++++++++------ .../attendance-settlement.service.spec.ts | 20 ++ .../attendance-settlement.service.ts | 12 ++ .../attendance/attendance.boundaries.spec.ts | 4 + .../attendance.lesson-session.spec.ts | 55 ++++++ .../src/attendance/attendance.module.ts | 4 +- .../src/attendance/attendance.service.spec.ts | 6 + .../src/attendance/attendance.service.ts | 4 + .../dingtalk-attendance.service.spec.ts | 69 +++++++ .../database-migrations.attendance.ts | 41 ++++ .../database/database-migrations.service.ts | 6 + .../src/entities/ding-leave-raw.entity.ts | 71 +++++++ apps/server/src/entities/index.ts | 1 + apps/server/src/integration/dingtalk.leave.ts | 95 ++++++++++ .../src/integration/dingtalk.service.ts | 14 ++ apps/server/src/integration/dingtalk.types.ts | 18 ++ 19 files changed, 746 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/entities/ding-leave-raw.entity.ts create mode 100644 apps/server/src/integration/dingtalk.leave.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 0cd513c..2423e75 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -121,6 +121,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.AttendanceDevice, Entities.AttendancePeriodConfig, Entities.DingAttendanceRaw, + Entities.DingLeaveRaw, Entities.Notification, Entities.StudentProfile, Entities.StudentEnrollment, diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index f238993..f545a42 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,10 +9,17 @@ describe('AttendanceImportService', () => { findOne: jest.fn(), save: jest.fn(), }; + const dingLeaveRawRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn((value: Record) => value), + save: jest.fn(), + }; const studentRepo = { findOne: jest.fn() }; - const studentDingMappingRepo = { findOne: jest.fn() }; + const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), + fetchDailyLeaveStatus: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), @@ -24,6 +31,7 @@ describe('AttendanceImportService', () => { jest.clearAllMocks(); service = new AttendanceImportService( dingRawRepo as never, + dingLeaveRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, @@ -304,4 +312,67 @@ describe('AttendanceImportService', () => { expect(event.userId).toBeUndefined(); } }); + + it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => { + dingTalkService.fetchDailyLeaveStatus.mockImplementation( + async (userId: string, workDate: string) => [ + { + userId, + workDate, + procInstId: `leave-${userId}-${workDate}`, + tagName: '请假', + leaveType: '事假', + beginTime: new Date(`${workDate}T08:00:00+08:00`), + endTime: new Date(`${workDate}T12:00:00+08:00`), + approvedAt: new Date(`${workDate}T09:00:00+08:00`), + duration: '0.5', + durationUnit: 'day', + }, + ], + ); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + dingLeaveRawRepo.save.mockImplementation(async (entities) => entities); + studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]); + dingLeaveRawRepo.find.mockResolvedValue([ + { dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' }, + ]); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-02', + userIds: ['ding-1', 'ding-2'], + autoMatch: true, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-1', + '2026-07-01', + ); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-2', + '2026-07-02', + ); + expect(dingLeaveRawRepo.save).toHaveBeenCalled(); + expect(result.synced).toBe(4); + expect(result.matched).toBe(1); + }); + + it('keeps syncing remaining users when one leave fetch fails', async () => { + dingTalkService.fetchDailyLeaveStatus + .mockRejectedValueOnce(new Error('DingTalk unavailable')) + .mockResolvedValue([]); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1', 'ding-2'], + autoMatch: false, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2); + expect(result.errors).toHaveLength(1); + expect(result.synced).toBe(0); + }); }); diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index 8895a17..87bc2f5 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -4,10 +4,15 @@ import { Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { DingAttendanceRaw, + DingLeaveRaw, Student, StudentDingMapping, } from '../entities'; -import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; +import { + DingTalkService, + DingTalkAttendanceResult, + DingTalkLeaveResult, +} from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; @@ -33,6 +38,8 @@ export class AttendanceImportService { constructor( @InjectRepository(DingAttendanceRaw) private readonly dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) + private readonly dingLeaveRawRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) @@ -154,6 +161,131 @@ export class AttendanceImportService { } } + /** + * 拉取钉钉已审批通过的请假记录并落库。 + * + * 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表, + * 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求, + * 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。 + */ + async syncLeaveStatusForLesson(params: { + startDate: string; + endDate: string; + userIds?: string[]; + autoMatch?: boolean; + }): Promise<{ synced: number; matched: number; errors: string[] }> { + const userIds = [...new Set((params.userIds ?? []).filter(Boolean))]; + if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] }; + if (params.startDate > params.endDate) { + throw new BadRequestException('开始日期不能晚于结束日期'); + } + + const errors: string[] = []; + let synced = 0; + + for (const date of this.enumerateDates(params.startDate, params.endDate)) { + for (const userId of userIds) { + try { + const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date); + for (const leave of leaves) { + await this.upsertLeave(leave); + synced++; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`请假同步失败 ${userId} ${date}: ${msg}`); + this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`); + } + } + } + + const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0; + if (synced > 0 || matched > 0) { + this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`); + } + return { synced, matched, errors }; + } + + private async upsertLeave(result: DingTalkLeaveResult): Promise { + const existing = await this.dingLeaveRawRepo.findOne({ + where: { dingId: result.procInstId }, + }); + if (existing) { + Object.assign(existing, { + dingUserId: result.userId, + workDate: result.workDate, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(existing); + return; + } + + const entity = this.dingLeaveRawRepo.create({ + dingUserId: result.userId, + userName: await this.resolveStudentName(result.userId), + workDate: result.workDate, + dingId: result.procInstId, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + matchStatus: 'unmatched', + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(entity); + } + + /** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */ + private async autoMatchLeaveRecords(): Promise { + const unmatched = await this.dingLeaveRawRepo.find({ + where: { matchStatus: 'unmatched' }, + }); + if (unmatched.length === 0) return 0; + + const mappings = await this.studentDingMappingRepo.find(); + const dingToStudentId = new Map(); + for (const mapping of mappings) { + dingToStudentId.set(mapping.dingUserId, mapping.studentId); + } + + let matched = 0; + const updates: DingLeaveRaw[] = []; + for (const record of unmatched) { + const studentId = dingToStudentId.get(record.dingUserId); + if (studentId == null) continue; + record.matchedStudentId = studentId; + record.matchStatus = 'matched'; + updates.push(record); + matched++; + } + if (updates.length > 0) { + await this.dingLeaveRawRepo.save(updates, { chunk: 50 }); + } + return matched; + } + + private enumerateDates(startDate: string, endDate: string): string[] { + const dates: string[] = []; + let cursor = this.parseDate(startDate); + const end = this.parseDate(endDate); + while (cursor.getTime() <= end.getTime()) { + dates.push(this.formatDate(cursor)); + cursor = new Date(cursor); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; + } + /** * DingTalk requires userIds, accepts at most 50 users per request, and * allows a maximum inclusive date range of 7 calendar days. diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index 5dafa4a..779c19a 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -4,6 +4,7 @@ import { DataSource, Repository, In, Between } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, + DingLeaveRaw, Class, Student, ClassSchedule, @@ -32,6 +33,7 @@ export class AttendanceLessonService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @@ -150,29 +152,34 @@ export class AttendanceLessonService { const existingStudentIds = new Set(existingRecords.map((record) => record.studentId)); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); - const updatedRecords = existingRecords.map((record) => { - record.student = studentsById.get(record.studentId)!; - // Preserve manual corrections only while the lesson is still in progress. - if (!finalize && record.source !== 'dingtalk') return record; + const updatedRecords = await Promise.all( + existingRecords.map(async (record) => { + record.student = studentsById.get(record.studentId)!; + // Preserve manual corrections only while the lesson is still in progress. + if (!finalize && record.source !== 'dingtalk') return record; - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(record.studentId) ?? [], - schedule, - lessonDate, - ); - record.status = mapDingTalkStatus(raw, effectiveFinalize); - Object.assign(record, getLessonPunchMetadata( - raw, + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(record.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - )); - record.remark = raw.some((item) => item.checkInTime || item.checkOutTime) - ? null - : effectiveFinalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果'; - return record; - }); + ); + const resolved = await this.resolveLessonStatus( + record.studentId, + raw, + schedule, + lessonDate, + effectiveFinalize, + ); + record.status = resolved.status; + Object.assign(record, getLessonPunchMetadata( + raw, + lessonDate, + schedule.startTime, + )); + record.remark = resolved.remark ?? null; + return record; + }), + ); for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; const raw = selectDingTalkRecordsForLesson( @@ -180,6 +187,13 @@ export class AttendanceLessonService { schedule, lessonDate, ); + const resolved = await this.resolveLessonStatus( + classStudent.studentId, + raw, + schedule, + lessonDate, + effectiveFinalize, + ); updatedRecords.push( recordRepo.create({ studentId: classStudent.studentId, @@ -189,18 +203,14 @@ export class AttendanceLessonService { attendanceSessionId: existing.id, attendanceDate: lessonDate, session: lessonSessionKey, - status: mapDingTalkStatus(raw, effectiveFinalize), + status: resolved.status, source: 'dingtalk', ...getLessonPunchMetadata( raw, lessonDate, schedule.startTime, ), - remark: raw.some((item) => item.checkInTime || item.checkOutTime) - ? undefined - : effectiveFinalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果', + remark: resolved.remark, }), ); } @@ -263,34 +273,39 @@ export class AttendanceLessonService { } const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); - const records = classStudents.map((classStudent) => { - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], - schedule, - lessonDate, - ); - return recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: schedule.classId!, - scheduleId, - attendanceSessionId: session.id, - attendanceDate: lessonDate, - session: lessonSessionKey, - status: mapDingTalkStatus(raw, finalize), - source: 'dingtalk', - ...getLessonPunchMetadata( - raw, + const records = await Promise.all( + classStudents.map(async (classStudent) => { + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(classStudent.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - ), - remark: raw.some((item) => item.checkInTime || item.checkOutTime) - ? undefined - : finalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果', - }); - }); + ); + const resolved = await this.resolveLessonStatus( + classStudent.studentId, + raw, + schedule, + lessonDate, + finalize, + ); + return recordRepo.create({ + studentId: classStudent.studentId, + student: classStudent.student, + classId: schedule.classId!, + scheduleId, + attendanceSessionId: session.id, + attendanceDate: lessonDate, + session: lessonSessionKey, + status: resolved.status, + source: 'dingtalk', + ...getLessonPunchMetadata( + raw, + lessonDate, + schedule.startTime, + ), + remark: resolved.remark, + }); + }), + ); const saved = await recordRepo.save(records); if (finalize) { session.status = 'completed'; @@ -302,6 +317,59 @@ export class AttendanceLessonService { }); } + /** + * 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与 + * 本节课时间窗口重叠,则记为 leave,而不是缺勤。 + */ + private async resolveLessonStatus( + studentId: number, + raw: DingAttendanceRaw[], + schedule: Pick, + lessonDate: string, + finalize: boolean, + ): Promise<{ status: string; remark?: string }> { + const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime); + if (!finalize) { + return { + status: mapDingTalkStatus(raw, false), + remark: hasPunch ? undefined : '未获取到钉钉打卡结果', + }; + } + if (hasPunch) return { status: 'present', remark: undefined }; + + const leave = await this.findApprovedLeaveForStudent(studentId, schedule, lessonDate); + if (leave) { + return { + status: 'leave', + remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`, + }; + } + return { status: 'absent', remark: '课程截止仍未打卡' }; + } + + private async findApprovedLeaveForStudent( + studentId: number, + schedule: Pick, + lessonDate: string, + ): Promise { + const leaves = await this.dingLeaveRawRepo.find({ + where: { matchedStudentId: studentId }, + }); + const window = getLessonAttendanceWindow(schedule, lessonDate); + const overlapping = leaves.filter( + (leave) => + leave.startTime && + leave.endTime && + leave.startTime.getTime() <= window.end && + leave.endTime.getTime() >= window.start, + ); + overlapping.sort( + (left, right) => + (right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0), + ); + return overlapping[0] ?? null; + } + private async fetchDingTalkRawByStudent( classId: number, schedule: Pick, diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index 1c17af6..ac84445 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -37,6 +37,7 @@ const createService = () => { }; const importService = { importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), + syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), }; const service = new AttendanceSettlementService( scheduleRepo as never, @@ -68,6 +69,25 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 2, '2026-07-13', 21, true, ); + expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ + startDate: '2026-07-13', + endDate: '2026-07-13', + userIds: ['ding-1'], + autoMatch: true, + }); + }); + + it('finalizes the lesson even when the leave sync fails', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([]); + importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); + + await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); + + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith( + 2, '2026-07-13', 21, true, + ); }); it('does not settle a lesson before its end time', async () => { diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index d4393aa..7472fae 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -129,6 +129,18 @@ export class AttendanceSettlementService { if (!imported.success || imported.errors.length > 0) { throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); } + try { + await this.importService.syncLeaveStatusForLesson({ + ...importRange, + userIds, + autoMatch: true, + }); + } catch (error: unknown) { + // 请假数据是补充信息,同步失败不应阻断结算;无请假的学生按缺勤处理。 + this.logger.warn( + `课程${schedule.id} ${lessonDate}钉钉请假同步失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } await this.attendanceService.createLessonAttendanceFromDingTalk( schedule.id, lessonDate, diff --git a/apps/server/src/attendance/attendance.boundaries.spec.ts b/apps/server/src/attendance/attendance.boundaries.spec.ts index 0587ee8..1dd35a7 100644 --- a/apps/server/src/attendance/attendance.boundaries.spec.ts +++ b/apps/server/src/attendance/attendance.boundaries.spec.ts @@ -28,6 +28,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { return new AttendanceService( {} as never, // attendanceRepo {} as never, // dingRawRepo + {} as never, // dingLeaveRawRepo {} as never, // classRepo {} as never, // studentRepo {} as never, // scheduleRepo @@ -217,6 +218,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, @@ -277,6 +279,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, @@ -339,6 +342,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index 4509b9d..339b83e 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -14,6 +14,7 @@ const createService = () => { count: jest.fn(), }; const dingRawRepo = { find: jest.fn() }; + const dingLeaveRawRepo = { find: jest.fn().mockResolvedValue([]) }; const scheduleRepo = { findOne: jest.fn() }; const classStudentRepo = { find: jest.fn() }; const sessionRepo = { @@ -37,6 +38,7 @@ const createService = () => { const service = new AttendanceService( attendanceRepo as never, dingRawRepo as never, + dingLeaveRawRepo as never, {} as never, {} as never, scheduleRepo as never, @@ -52,6 +54,7 @@ const createService = () => { service, attendanceRepo, dingRawRepo, + dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo, @@ -151,6 +154,58 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { expect(result.session.status).toBe('completed'); }); + it('finalizes a missing punch as leave when an approved DingTalk leave overlaps the lesson', async () => { + const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = + createService(); + scheduleRepo.findOne.mockResolvedValue(endedSchedule); + sessionRepo.findOne.mockResolvedValue(null); + classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); + dingRawRepo.find.mockResolvedValue([]); + dingLeaveRawRepo.find.mockResolvedValue([ + { + startTime: new Date('2026-07-11T08:00:00+08:00'), + endTime: new Date('2026-07-11T12:00:00+08:00'), + approvedAt: new Date('2026-07-10T15:00:00+08:00'), + leaveType: '事假', + tagName: '请假', + }, + ]); + + await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true); + + expect(attendanceRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ + studentId: 1, + status: 'leave', + remark: '钉钉请假已通过(事假)', + }), + ]); + }); + + it('keeps a leave student pending before the lesson is finalized', async () => { + const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = + createService(); + scheduleRepo.findOne.mockResolvedValue(endedSchedule); + sessionRepo.findOne.mockResolvedValue(null); + classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); + dingRawRepo.find.mockResolvedValue([]); + dingLeaveRawRepo.find.mockResolvedValue([ + { + startTime: new Date('2026-07-11T08:00:00+08:00'), + endTime: new Date('2026-07-11T12:00:00+08:00'), + approvedAt: new Date('2026-07-10T15:00:00+08:00'), + leaveType: '事假', + tagName: '请假', + }, + ]); + + await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21); + + expect(attendanceRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ studentId: 1, status: 'pending' }), + ]); + }); + it('returns student relations after the first pull', async () => { const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = createService(); diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 136406f..348e813 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; +import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceSettlementService } from './attendance-settlement.service'; @@ -12,7 +12,7 @@ import { IntegrationModule } from '../integration/integration.module'; @Module({ imports: [ - TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), + TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), OperationLogsModule, IntegrationModule, ], diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index 36d0603..c00e7ae 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -8,6 +8,7 @@ import { AttendanceSession } from '../entities/attendance-session.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; +import { DingLeaveRaw } from '../entities/ding-leave-raw.entity'; import { Class } from '../entities/class.entity'; import { Student } from '../entities/student.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; @@ -50,6 +51,7 @@ describe('AttendanceService — batchCreate', () => { AttendanceService, { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo }, { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo }, + { provide: getRepositoryToken(DingLeaveRaw), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: mockClassRepo }, { provide: getRepositoryToken(Student), useValue: mockStudentRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, @@ -156,6 +158,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => { {} as never, {} as never, {} as never, + {} as never, classStudentRepo as never, mappingRepo as never, classTeacherRepo as never, @@ -231,6 +234,7 @@ describe('AttendanceService — DingTalk raw query', () => { {} as never, {} as never, {} as never, + {} as never, classStudentRepo as never, mappingRepo as never, {} as never, @@ -299,6 +303,7 @@ describe('AttendanceService — attendance device display mappings', () => { {} as never, {} as never, {} as never, + {} as never, attendanceDeviceRepo as never, {} as never, {} as never, @@ -408,6 +413,7 @@ describe('AttendanceService — session serialization', () => { {} as never, {} as never, {} as never, + {} as never, { find: jest.fn().mockResolvedValue([]) } as never, {} as never, dataSourceMock as never, diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index b12848e..d13b2ab 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -7,6 +7,7 @@ import { AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, + DingLeaveRaw, Class, Student, ClassSchedule, @@ -35,6 +36,8 @@ export class AttendanceService { private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) + private dingLeaveRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) @@ -69,6 +72,7 @@ export class AttendanceService { this.lessonService = new AttendanceLessonService( this.attendanceRepo, this.dingRawRepo, + this.dingLeaveRawRepo, this.classRepo, this.studentRepo, this.scheduleRepo, diff --git a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts index e8e79b0..4426b1a 100644 --- a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts +++ b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts @@ -94,4 +94,73 @@ describe('DingTalkService — attendance records', () => { }), ); }); + + it('fetches approved leave approvals from the daily attendance data API', async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + errcode: 0, + errmsg: 'ok', + result: { + userid: 'ding-1', + work_date: '2026-07-12 00:00:00', + approve_list: [ + { + procInst_id: 'PRO-LEAVE-1', + tag_name: '请假', + sub_type: '事假', + biz_type: 3, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 12:00:00', + gmt_finished: '2026-07-11 18:00:00', + duration: '0.5', + duration_unit: 'day', + }, + { + // 审批中(无 gmt_finished)的请假不应返回 + procInst_id: 'PRO-LEAVE-2', + tag_name: '请假', + sub_type: '病假', + biz_type: 3, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 12:00:00', + duration: '0.5', + duration_unit: 'day', + }, + { + // 出差(biz_type=2)不应返回 + procInst_id: 'PRO-TRIP-1', + tag_name: '出差', + sub_type: '出差', + biz_type: 2, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 18:00:00', + gmt_finished: '2026-07-11 18:00:00', + }, + ], + }, + }), + }) as jest.MockedFunction; + + const leaves = await service.fetchDailyLeaveStatus('ding-1', '2026-07-12'); + + expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({ + userid: 'ding-1', + work_date: '2026-07-12 00:00:00', + }); + expect(leaves).toHaveLength(1); + expect(leaves[0]).toEqual( + expect.objectContaining({ + userId: 'ding-1', + workDate: '2026-07-12', + procInstId: 'PRO-LEAVE-1', + leaveType: '事假', + tagName: '请假', + beginTime: new Date('2026-07-12T08:00:00+08:00'), + endTime: new Date('2026-07-12T12:00:00+08:00'), + approvedAt: new Date('2026-07-11T18:00:00+08:00'), + duration: '0.5', + durationUnit: 'day', + }), + ); + }); }); diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index b68f9fd..61f918f 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -78,6 +78,47 @@ export async function ensureCourseAttendanceSchema( }); } +export async function ensureDingLeaveSchema( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + await runner.query(` + CREATE TABLE IF NOT EXISTS ding_leave_raw ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + ding_user_id VARCHAR(100) NOT NULL, + user_name VARCHAR(100) NOT NULL DEFAULT '', + work_date DATE NOT NULL, + ding_id VARCHAR(100) NOT NULL, + leave_type VARCHAR(100) NOT NULL DEFAULT '', + tag_name VARCHAR(50) NOT NULL DEFAULT '', + start_time DATETIME, + end_time DATETIME, + approved_at DATETIME, + duration VARCHAR(20) NOT NULL DEFAULT '', + duration_unit VARCHAR(20) NOT NULL DEFAULT '', + match_status VARCHAR(20) NOT NULL DEFAULT 'unmatched', + matched_student_id INTEGER, + raw_data TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + const createIndex = async (sql: string) => { + try { + await runner.query(sql); + } catch { + // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. + } + }; + await createIndex('CREATE UNIQUE INDEX uq_ding_leave_raw_ding_id ON ding_leave_raw (ding_id)'); + await createIndex('CREATE INDEX idx_ding_leave_raw_work_date ON ding_leave_raw (work_date)'); + await createIndex('CREATE INDEX idx_ding_leave_raw_match_status ON ding_leave_raw (match_status)'); + logger.log('已确保钉钉请假原始表 ding_leave_raw'); + }); +} + export async function protectAttendanceHistory( dataSource: DataSource, logger: Logger, diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 2b601aa..63848f0 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -14,6 +14,7 @@ import { import { ensureAiConfigTable } from './database-migrations.ai'; import { ensureCourseAttendanceSchema, + ensureDingLeaveSchema, protectAttendanceHistory, } from './database-migrations.attendance'; import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill'; @@ -29,6 +30,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.ensureSyncStateLeaseColumns(); await this.ensureStudentProfileCollegeColumns(); await this.ensureCourseAttendanceSchema(); + await this.ensureDingLeaveSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); await this.backfillOrganizations(); @@ -85,6 +87,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { return ensureCourseAttendanceSchema(this.dataSource, this.logger); } + async ensureDingLeaveSchema(): Promise { + return ensureDingLeaveSchema(this.dataSource, this.logger); + } + async backfillOrganizations(): Promise { return backfillOrganizations(this.dataSource); } diff --git a/apps/server/src/entities/ding-leave-raw.entity.ts b/apps/server/src/entities/ding-leave-raw.entity.ts new file mode 100644 index 0000000..f348ff5 --- /dev/null +++ b/apps/server/src/entities/ding-leave-raw.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Student } from './student.entity'; + +@Entity('ding_leave_raw') +@Index(['workDate']) +@Index(['matchStatus']) +export class DingLeaveRaw { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'ding_user_id', length: 100 }) + dingUserId: string; + + @Column({ name: 'user_name', length: 100 }) + userName: string; + + @Column({ name: 'work_date', type: 'date' }) + workDate: string; + + @Column({ name: 'ding_id', length: 100, unique: true }) + dingId: string; + + @Column({ name: 'leave_type', length: 100 }) + leaveType: string; + + @Column({ name: 'tag_name', length: 50 }) + tagName: string; + + @Column({ name: 'start_time', type: 'datetime', nullable: true }) + startTime: Date | null; + + @Column({ name: 'end_time', type: 'datetime', nullable: true }) + endTime: Date | null; + + @Column({ name: 'approved_at', type: 'datetime', nullable: true }) + approvedAt: Date | null; + + @Column({ length: 20 }) + duration: string; + + @Column({ name: 'duration_unit', length: 20 }) + durationUnit: string; + + @Column({ name: 'match_status', length: 20, default: 'unmatched' }) + matchStatus: string; + + @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) + matchedStudentId: number; + + @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'matched_student_id' }) + matchedStudent: Student; + + @Column({ name: 'raw_data', type: 'text', nullable: true }) + rawData: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 90f991b..ed8af6e 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -27,6 +27,7 @@ export { AttendanceSession } from './attendance-session.entity'; export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity'; export { AttendancePeriodConfig } from './attendance-period-config.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity'; +export { DingLeaveRaw } from './ding-leave-raw.entity'; export { SyncLog } from './sync-log.entity'; export { SyncState } from './sync-state.entity'; export { ExpenseType } from './expense-type.entity'; diff --git a/apps/server/src/integration/dingtalk.leave.ts b/apps/server/src/integration/dingtalk.leave.ts new file mode 100644 index 0000000..65fa92a --- /dev/null +++ b/apps/server/src/integration/dingtalk.leave.ts @@ -0,0 +1,95 @@ +// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同) +import type { + DingTalkLeaveResult, + DingTalkServiceContext, +} from './dingtalk.types'; + +interface DingTalkGetUpdateDataResponse { + errcode: number; + errmsg: string; + result?: { + userid?: string; + work_date?: string; + approve_list?: Array<{ + procInst_id?: string; + tag_name?: string; + sub_type?: string; + biz_type?: number; + begin_time?: string; + end_time?: string; + gmt_finished?: string; + duration?: string; + duration_unit?: string; + }>; + }; +} + +/** + * 钉钉请假数据客户端。 + * + * 使用「获取用户考勤数据」接口(topapi/attendance/getupdatedata),按用户+工作日 + * 返回当天打卡结果与审批单列表;这里只取 biz_type=3(请假)且已审批完成 + * (gmt_finished 非空)的记录,保证结算时不会把审批中的请假误判为请假。 + */ +export class DingTalkLeaveClient { + constructor(private readonly context: DingTalkServiceContext) {} + + async fetchDailyLeaveStatus( + userId: string, + workDate: string, + ): Promise { + if (!(await this.context.isConfigured())) throw new Error('DingTalk not configured'); + if (!userId) throw new Error('钉钉请假查询 userId 不能为空'); + + const token = await this.context.getAccessToken(); + await this.context.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/getupdatedata?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userid: userId, + work_date: workDate.includes(' ') ? workDate : `${workDate} 00:00:00`, + }), + }, + ); + const data = (await res.json()) as DingTalkGetUpdateDataResponse; + if (data.errcode !== 0) { + throw new Error(`钉钉请假数据获取失败: ${data.errmsg}`); + } + + const result = data.result; + if (!result) return []; + const approveList = result.approve_list ?? []; + + return approveList + .filter( + (approval) => + approval.biz_type === 3 && + approval.gmt_finished && + approval.procInst_id && + approval.begin_time && + approval.end_time, + ) + .map((approval) => ({ + userId: result.userid ?? userId, + workDate, + procInstId: approval.procInst_id!, + tagName: approval.tag_name ?? '请假', + leaveType: approval.sub_type ?? '', + beginTime: this.parseDingDate(approval.begin_time!), + endTime: this.parseDingDate(approval.end_time!), + approvedAt: this.parseDingDate(approval.gmt_finished!), + duration: approval.duration ?? '', + durationUnit: approval.duration_unit ?? '', + })); + } + + /** 钉钉返回的日期可能是 '2026-08-01' 或 '2026-08-01 09:00:00',统一按东八区解析。 */ + private parseDingDate(value: string): Date { + const normalized = value.includes(' ') ? value.replace(' ', 'T') : `${value}T00:00:00`; + const date = new Date(`${normalized}+08:00`); + return Number.isNaN(date.getTime()) ? new Date(normalized) : date; + } +} diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 433c4b4..e25c055 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -25,12 +25,14 @@ import type { OrgDeptNodeWithUsers, } from './dingtalk.types'; import { DingTalkAttendanceClient } from './dingtalk.attendance'; +import { DingTalkLeaveClient } from './dingtalk.leave'; import { DingTalkShiftClient } from './dingtalk.shifts'; import { DingTalkGroupClient } from './dingtalk.groups'; import { DingTalkScheduleClient } from './dingtalk.schedules'; export type { DingTalkAttendanceResult, + DingTalkLeaveResult, DingTalkGroupParams, DingTalkGroupSummary, DingTalkGroupUpdateParams, @@ -55,6 +57,7 @@ export class DingTalkService implements DingTalkServiceContext { private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; private attendanceClient?: DingTalkAttendanceClient; + private leaveClient?: DingTalkLeaveClient; private shiftClient?: DingTalkShiftClient; private groupClient?: DingTalkGroupClient; private scheduleClient?: DingTalkScheduleClient; @@ -73,6 +76,11 @@ export class DingTalkService implements DingTalkServiceContext { return this.attendanceClient; } + private get leaves(): DingTalkLeaveClient { + if (!this.leaveClient) this.leaveClient = new DingTalkLeaveClient(this); + return this.leaveClient; + } + private get shifts(): DingTalkShiftClient { if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this); return this.shiftClient; @@ -367,6 +375,12 @@ export class DingTalkService implements DingTalkServiceContext { return this.attendance.fetchAttendanceResults(...args); } + async fetchDailyLeaveStatus( + ...args: Parameters + ) { + return this.leaves.fetchDailyLeaveStatus(...args); + } + async upsertShift(...args: Parameters) { return this.shifts.upsertShift(...args); } diff --git a/apps/server/src/integration/dingtalk.types.ts b/apps/server/src/integration/dingtalk.types.ts index 1b4bd26..764fbbe 100644 --- a/apps/server/src/integration/dingtalk.types.ts +++ b/apps/server/src/integration/dingtalk.types.ts @@ -63,6 +63,24 @@ export interface DingTalkAttendanceResult { deviceId?: string; } +/** 钉钉已审批通过的请假记录 — 对齐 dws 考勤数据中的审批单列表。 */ +export interface DingTalkLeaveResult { + userId: string; + workDate: string; + /** 钉钉审批单 ID */ + procInstId: string; + /** 审批单类型名称,例如 请假 */ + tagName: string; + /** 请假类型,例如 年假 / 事假 / 病假 */ + leaveType: string; + beginTime: Date; + endTime: Date; + /** 审批完成时间;为 null 表示仍在审批中,不纳入结算 */ + approvedAt: Date | null; + duration: string; + durationUnit: string; +} + // ── 组织架构 API 类型 ── export interface DingTalkDeptListResponse { From 6ba7f4e3d0c30d27b6f3802fa73ddf345baddb2d Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:51:06 +0800 Subject: [PATCH 18/19] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E8=80=83=E5=8B=A4=E8=AE=B0=E5=BD=95=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=20helper=EF=BC=8C=E6=B6=88=E9=99=A4=E9=87=8D=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/attendance-lesson.service.ts | 139 +++++++++++------- 1 file changed, 83 insertions(+), 56 deletions(-) diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index 779c19a..ed8d4e6 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -182,36 +182,18 @@ export class AttendanceLessonService { ); for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], - schedule, - lessonDate, - ); - const resolved = await this.resolveLessonStatus( - classStudent.studentId, - raw, - schedule, - lessonDate, - effectiveFinalize, - ); updatedRecords.push( - recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: schedule.classId!, + await this.buildLessonRecord( + recordRepo, + rawByStudent, + classStudent, + schedule, + lessonDate, + lessonSessionKey, + existing.id, scheduleId, - attendanceSessionId: existing.id, - attendanceDate: lessonDate, - session: lessonSessionKey, - status: resolved.status, - source: 'dingtalk', - ...getLessonPunchMetadata( - raw, - lessonDate, - schedule.startTime, - ), - remark: resolved.remark, - }), + effectiveFinalize, + ), ); } @@ -274,37 +256,19 @@ export class AttendanceLessonService { const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const records = await Promise.all( - classStudents.map(async (classStudent) => { - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], + classStudents.map((classStudent) => + this.buildLessonRecord( + recordRepo, + rawByStudent, + classStudent, schedule, lessonDate, - ); - const resolved = await this.resolveLessonStatus( - classStudent.studentId, - raw, - schedule, - lessonDate, - finalize, - ); - return recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: schedule.classId!, + lessonSessionKey, + session.id, scheduleId, - attendanceSessionId: session.id, - attendanceDate: lessonDate, - session: lessonSessionKey, - status: resolved.status, - source: 'dingtalk', - ...getLessonPunchMetadata( - raw, - lessonDate, - schedule.startTime, - ), - remark: resolved.remark, - }); - }), + finalize, + ), + ), ); const saved = await recordRepo.save(records); if (finalize) { @@ -347,6 +311,69 @@ export class AttendanceLessonService { return { status: 'absent', remark: '课程截止仍未打卡' }; } + private createLessonRecord( + recordRepo: Repository, + classStudent: ClassStudent, + raw: DingAttendanceRaw[], + options: { + schedule: Pick; + scheduleId: number; + lessonDate: string; + lessonSessionKey: string; + attendanceSessionId: number; + status: string; + remark?: string; + }, + ): AttendanceRecord { + return recordRepo.create({ + studentId: classStudent.studentId, + student: classStudent.student, + classId: options.schedule.classId!, + scheduleId: options.scheduleId, + attendanceSessionId: options.attendanceSessionId, + attendanceDate: options.lessonDate, + session: options.lessonSessionKey, + status: options.status, + source: 'dingtalk', + ...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime), + remark: options.remark, + }); + } + + private async buildLessonRecord( + recordRepo: Repository, + rawByStudent: Map, + classStudent: ClassStudent, + schedule: Pick, + lessonDate: string, + lessonSessionKey: string, + attendanceSessionId: number, + scheduleId: number, + finalize: boolean, + ): Promise { + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(classStudent.studentId) ?? [], + schedule, + lessonDate, + ); + const resolved = await this.resolveLessonStatus( + classStudent.studentId, + raw, + schedule, + lessonDate, + finalize, + ); + return this.createLessonRecord(recordRepo, classStudent, raw, { + schedule, + scheduleId, + lessonDate, + lessonSessionKey, + attendanceSessionId, + status: resolved.status, + remark: resolved.remark, + }); + } + private async findApprovedLeaveForStudent( studentId: number, schedule: Pick, From b882411f42e55e91945a31f6d963d862ecd5b7c9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:55:36 +0800 Subject: [PATCH 19/19] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E6=9C=8D=E5=8A=A1=E6=96=87=E4=BB=B6=E5=B9=B6=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20aislop=20=E5=85=A8=E9=A1=B9=E7=9B=AE=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/archive/archive-report.learning.ts | 2 +- .../attendance-import.service.spec.ts | 70 -------- .../attendance/attendance-import.service.ts | 134 +------------- .../attendance-leave-sync.service.spec.ts | 91 ++++++++++ .../attendance-leave-sync.service.ts | 166 ++++++++++++++++++ .../attendance/attendance-lesson-status.ts | 140 +++++++++++++++ .../attendance/attendance-lesson.service.ts | 127 +------------- .../attendance-settlement.service.spec.ts | 13 +- .../attendance-settlement.service.ts | 4 +- .../src/attendance/attendance.module.ts | 8 +- 10 files changed, 424 insertions(+), 331 deletions(-) create mode 100644 apps/server/src/attendance/attendance-leave-sync.service.spec.ts create mode 100644 apps/server/src/attendance/attendance-leave-sync.service.ts create mode 100644 apps/server/src/attendance/attendance-lesson-status.ts diff --git a/apps/server/src/archive/archive-report.learning.ts b/apps/server/src/archive/archive-report.learning.ts index db9c133..8125707 100644 --- a/apps/server/src/archive/archive-report.learning.ts +++ b/apps/server/src/archive/archive-report.learning.ts @@ -37,7 +37,7 @@ export function buildLearning(learnings: LearningRecord[], now: string): string `); } -export function buildResult(result: ResultArchive | null, now: string): string { +export function buildResult(result: ResultArchive | null, _now: string): string { if (!result) return ''; return sectionFrame(` diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index f545a42..1789c00 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,17 +9,10 @@ describe('AttendanceImportService', () => { findOne: jest.fn(), save: jest.fn(), }; - const dingLeaveRawRepo = { - find: jest.fn(), - findOne: jest.fn(), - create: jest.fn((value: Record) => value), - save: jest.fn(), - }; const studentRepo = { findOne: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), - fetchDailyLeaveStatus: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), @@ -31,7 +24,6 @@ describe('AttendanceImportService', () => { jest.clearAllMocks(); service = new AttendanceImportService( dingRawRepo as never, - dingLeaveRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, @@ -313,66 +305,4 @@ describe('AttendanceImportService', () => { } }); - it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => { - dingTalkService.fetchDailyLeaveStatus.mockImplementation( - async (userId: string, workDate: string) => [ - { - userId, - workDate, - procInstId: `leave-${userId}-${workDate}`, - tagName: '请假', - leaveType: '事假', - beginTime: new Date(`${workDate}T08:00:00+08:00`), - endTime: new Date(`${workDate}T12:00:00+08:00`), - approvedAt: new Date(`${workDate}T09:00:00+08:00`), - duration: '0.5', - durationUnit: 'day', - }, - ], - ); - dingLeaveRawRepo.findOne.mockResolvedValue(null); - dingLeaveRawRepo.save.mockImplementation(async (entities) => entities); - studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]); - dingLeaveRawRepo.find.mockResolvedValue([ - { dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' }, - ]); - - const result = await service.syncLeaveStatusForLesson({ - startDate: '2026-07-01', - endDate: '2026-07-02', - userIds: ['ding-1', 'ding-2'], - autoMatch: true, - }); - - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4); - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( - 'ding-1', - '2026-07-01', - ); - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( - 'ding-2', - '2026-07-02', - ); - expect(dingLeaveRawRepo.save).toHaveBeenCalled(); - expect(result.synced).toBe(4); - expect(result.matched).toBe(1); - }); - - it('keeps syncing remaining users when one leave fetch fails', async () => { - dingTalkService.fetchDailyLeaveStatus - .mockRejectedValueOnce(new Error('DingTalk unavailable')) - .mockResolvedValue([]); - dingLeaveRawRepo.findOne.mockResolvedValue(null); - - const result = await service.syncLeaveStatusForLesson({ - startDate: '2026-07-01', - endDate: '2026-07-01', - userIds: ['ding-1', 'ding-2'], - autoMatch: false, - }); - - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2); - expect(result.errors).toHaveLength(1); - expect(result.synced).toBe(0); - }); }); diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index 87bc2f5..8895a17 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -4,15 +4,10 @@ import { Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { DingAttendanceRaw, - DingLeaveRaw, Student, StudentDingMapping, } from '../entities'; -import { - DingTalkService, - DingTalkAttendanceResult, - DingTalkLeaveResult, -} from '../integration/dingtalk.service'; +import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; @@ -38,8 +33,6 @@ export class AttendanceImportService { constructor( @InjectRepository(DingAttendanceRaw) private readonly dingRawRepo: Repository, - @InjectRepository(DingLeaveRaw) - private readonly dingLeaveRawRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) @@ -161,131 +154,6 @@ export class AttendanceImportService { } } - /** - * 拉取钉钉已审批通过的请假记录并落库。 - * - * 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表, - * 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求, - * 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。 - */ - async syncLeaveStatusForLesson(params: { - startDate: string; - endDate: string; - userIds?: string[]; - autoMatch?: boolean; - }): Promise<{ synced: number; matched: number; errors: string[] }> { - const userIds = [...new Set((params.userIds ?? []).filter(Boolean))]; - if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] }; - if (params.startDate > params.endDate) { - throw new BadRequestException('开始日期不能晚于结束日期'); - } - - const errors: string[] = []; - let synced = 0; - - for (const date of this.enumerateDates(params.startDate, params.endDate)) { - for (const userId of userIds) { - try { - const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date); - for (const leave of leaves) { - await this.upsertLeave(leave); - synced++; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`请假同步失败 ${userId} ${date}: ${msg}`); - this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`); - } - } - } - - const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0; - if (synced > 0 || matched > 0) { - this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`); - } - return { synced, matched, errors }; - } - - private async upsertLeave(result: DingTalkLeaveResult): Promise { - const existing = await this.dingLeaveRawRepo.findOne({ - where: { dingId: result.procInstId }, - }); - if (existing) { - Object.assign(existing, { - dingUserId: result.userId, - workDate: result.workDate, - leaveType: result.leaveType, - tagName: result.tagName, - startTime: result.beginTime, - endTime: result.endTime, - approvedAt: result.approvedAt, - duration: result.duration, - durationUnit: result.durationUnit, - rawData: JSON.stringify(result), - }); - await this.dingLeaveRawRepo.save(existing); - return; - } - - const entity = this.dingLeaveRawRepo.create({ - dingUserId: result.userId, - userName: await this.resolveStudentName(result.userId), - workDate: result.workDate, - dingId: result.procInstId, - leaveType: result.leaveType, - tagName: result.tagName, - startTime: result.beginTime, - endTime: result.endTime, - approvedAt: result.approvedAt, - duration: result.duration, - durationUnit: result.durationUnit, - matchStatus: 'unmatched', - rawData: JSON.stringify(result), - }); - await this.dingLeaveRawRepo.save(entity); - } - - /** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */ - private async autoMatchLeaveRecords(): Promise { - const unmatched = await this.dingLeaveRawRepo.find({ - where: { matchStatus: 'unmatched' }, - }); - if (unmatched.length === 0) return 0; - - const mappings = await this.studentDingMappingRepo.find(); - const dingToStudentId = new Map(); - for (const mapping of mappings) { - dingToStudentId.set(mapping.dingUserId, mapping.studentId); - } - - let matched = 0; - const updates: DingLeaveRaw[] = []; - for (const record of unmatched) { - const studentId = dingToStudentId.get(record.dingUserId); - if (studentId == null) continue; - record.matchedStudentId = studentId; - record.matchStatus = 'matched'; - updates.push(record); - matched++; - } - if (updates.length > 0) { - await this.dingLeaveRawRepo.save(updates, { chunk: 50 }); - } - return matched; - } - - private enumerateDates(startDate: string, endDate: string): string[] { - const dates: string[] = []; - let cursor = this.parseDate(startDate); - const end = this.parseDate(endDate); - while (cursor.getTime() <= end.getTime()) { - dates.push(this.formatDate(cursor)); - cursor = new Date(cursor); - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - return dates; - } - /** * DingTalk requires userIds, accepts at most 50 users per request, and * allows a maximum inclusive date range of 7 calendar days. diff --git a/apps/server/src/attendance/attendance-leave-sync.service.spec.ts b/apps/server/src/attendance/attendance-leave-sync.service.spec.ts new file mode 100644 index 0000000..a166a1c --- /dev/null +++ b/apps/server/src/attendance/attendance-leave-sync.service.spec.ts @@ -0,0 +1,91 @@ +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; +import { DingTalkService } from '../integration/dingtalk.service'; + +describe('AttendanceLeaveSyncService', () => { + const dingLeaveRawRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn((value: Record) => value), + save: jest.fn(), + }; + const studentRepo = { findOne: jest.fn() }; + const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; + const dingTalkService = { + fetchDailyLeaveStatus: jest.fn(), + }; + + let service: AttendanceLeaveSyncService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AttendanceLeaveSyncService( + dingLeaveRawRepo as never, + studentRepo as never, + studentDingMappingRepo as never, + dingTalkService as unknown as DingTalkService, + ); + }); + + it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => { + dingTalkService.fetchDailyLeaveStatus.mockImplementation( + async (userId: string, workDate: string) => [ + { + userId, + workDate, + procInstId: `leave-${userId}-${workDate}`, + tagName: '请假', + leaveType: '事假', + beginTime: new Date(`${workDate}T08:00:00+08:00`), + endTime: new Date(`${workDate}T12:00:00+08:00`), + approvedAt: new Date(`${workDate}T09:00:00+08:00`), + duration: '0.5', + durationUnit: 'day', + }, + ], + ); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + dingLeaveRawRepo.save.mockImplementation(async (entities) => entities); + studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]); + dingLeaveRawRepo.find.mockResolvedValue([ + { dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' }, + ]); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-02', + userIds: ['ding-1', 'ding-2'], + autoMatch: true, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-1', + '2026-07-01', + ); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-2', + '2026-07-02', + ); + expect(dingLeaveRawRepo.save).toHaveBeenCalled(); + expect(result.synced).toBe(4); + expect(result.matched).toBe(1); + }); + + it('keeps syncing remaining users when one leave fetch fails', async () => { + dingTalkService.fetchDailyLeaveStatus + .mockRejectedValueOnce(new Error('DingTalk unavailable')) + .mockResolvedValue([]); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1', 'ding-2'], + autoMatch: false, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2); + expect(result.errors).toHaveLength(1); + expect(result.synced).toBe(0); + }); +}); diff --git a/apps/server/src/attendance/attendance-leave-sync.service.ts b/apps/server/src/attendance/attendance-leave-sync.service.ts new file mode 100644 index 0000000..3a564af --- /dev/null +++ b/apps/server/src/attendance/attendance-leave-sync.service.ts @@ -0,0 +1,166 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DingLeaveRaw, Student, StudentDingMapping } from '../entities'; +import { DingTalkService, DingTalkLeaveResult } from '../integration/dingtalk.service'; + +/** + * 钉钉请假数据同步服务。 + * + * 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表, + * 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求, + * 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。 + */ +@Injectable() +export class AttendanceLeaveSyncService { + private readonly logger = new Logger(AttendanceLeaveSyncService.name); + + constructor( + @InjectRepository(DingLeaveRaw) + private readonly dingLeaveRawRepo: Repository, + @InjectRepository(Student) + private readonly studentRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, + private readonly dingTalkService: DingTalkService, + ) {} + + async syncLeaveStatusForLesson(params: { + startDate: string; + endDate: string; + userIds?: string[]; + autoMatch?: boolean; + }): Promise<{ synced: number; matched: number; errors: string[] }> { + const userIds = [...new Set((params.userIds ?? []).filter(Boolean))]; + if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] }; + if (params.startDate > params.endDate) { + throw new BadRequestException('开始日期不能晚于结束日期'); + } + + const errors: string[] = []; + let synced = 0; + + for (const date of this.enumerateDates(params.startDate, params.endDate)) { + for (const userId of userIds) { + try { + const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date); + for (const leave of leaves) { + await this.upsertLeave(leave); + synced++; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`请假同步失败 ${userId} ${date}: ${msg}`); + this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`); + } + } + } + + const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0; + if (synced > 0 || matched > 0) { + this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`); + } + return { synced, matched, errors }; + } + + private async upsertLeave(result: DingTalkLeaveResult): Promise { + const existing = await this.dingLeaveRawRepo.findOne({ + where: { dingId: result.procInstId }, + }); + if (existing) { + Object.assign(existing, { + dingUserId: result.userId, + workDate: result.workDate, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(existing); + return; + } + + const entity = this.dingLeaveRawRepo.create({ + dingUserId: result.userId, + userName: await this.resolveStudentName(result.userId), + workDate: result.workDate, + dingId: result.procInstId, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + matchStatus: 'unmatched', + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(entity); + } + + /** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */ + private async autoMatchLeaveRecords(): Promise { + const unmatched = await this.dingLeaveRawRepo.find({ + where: { matchStatus: 'unmatched' }, + }); + if (unmatched.length === 0) return 0; + + const mappings = await this.studentDingMappingRepo.find(); + const dingToStudentId = new Map(); + for (const mapping of mappings) { + dingToStudentId.set(mapping.dingUserId, mapping.studentId); + } + + let matched = 0; + const updates: DingLeaveRaw[] = []; + for (const record of unmatched) { + const studentId = dingToStudentId.get(record.dingUserId); + if (studentId == null) continue; + record.matchedStudentId = studentId; + record.matchStatus = 'matched'; + updates.push(record); + matched++; + } + if (updates.length > 0) { + await this.dingLeaveRawRepo.save(updates, { chunk: 50 }); + } + return matched; + } + + private enumerateDates(startDate: string, endDate: string): string[] { + const dates: string[] = []; + let cursor = this.parseDate(startDate); + const end = this.parseDate(endDate); + while (cursor.getTime() <= end.getTime()) { + dates.push(this.formatDate(cursor)); + cursor = new Date(cursor); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; + } + + private parseDate(value: string): Date { + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`无效日期: ${value}`); + } + return date; + } + + private formatDate(value: Date): string { + return value.toISOString().slice(0, 10); + } + + private async resolveStudentName(dingUserId: string): Promise { + const mapping = await this.studentDingMappingRepo.findOne({ + where: { dingUserId }, + }); + if (!mapping) return ''; + const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } }); + return student?.name || ''; + } +} diff --git a/apps/server/src/attendance/attendance-lesson-status.ts b/apps/server/src/attendance/attendance-lesson-status.ts new file mode 100644 index 0000000..219def7 --- /dev/null +++ b/apps/server/src/attendance/attendance-lesson-status.ts @@ -0,0 +1,140 @@ +import { Repository } from 'typeorm'; +import { + AttendanceRecord, + ClassSchedule, + ClassStudent, + DingAttendanceRaw, + DingLeaveRaw, +} from '../entities'; +import { + getLessonAttendanceWindow, + getLessonPunchMetadata, + mapDingTalkStatus, + selectDingTalkRecordsForLesson, + type LessonScheduleLike, +} from './attendance-dingtalk'; + +type LessonRecordSchedule = Pick< + ClassSchedule, + 'classId' | 'startTime' | 'endTime' | 'attendanceAdvanceMinutes' +>; + +/** + * 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与 + * 本节课时间窗口重叠,则记为 leave,而不是缺勤。 + */ +export async function resolveLessonStatus( + dingLeaveRawRepo: Repository, + studentId: number, + raw: DingAttendanceRaw[], + schedule: LessonScheduleLike, + lessonDate: string, + finalize: boolean, +): Promise<{ status: string; remark?: string }> { + const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime); + if (!finalize) { + return { + status: mapDingTalkStatus(raw, false), + remark: hasPunch ? undefined : '未获取到钉钉打卡结果', + }; + } + if (hasPunch) return { status: 'present', remark: undefined }; + + const leave = await findApprovedLeaveForStudent(dingLeaveRawRepo, studentId, schedule, lessonDate); + if (leave) { + return { + status: 'leave', + remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`, + }; + } + return { status: 'absent', remark: '课程截止仍未打卡' }; +} + +export function createLessonRecord( + recordRepo: Repository, + classStudent: ClassStudent, + raw: DingAttendanceRaw[], + options: { + schedule: LessonRecordSchedule; + scheduleId: number; + lessonDate: string; + lessonSessionKey: string; + attendanceSessionId: number; + status: string; + remark?: string; + }, +): AttendanceRecord { + return recordRepo.create({ + studentId: classStudent.studentId, + student: classStudent.student, + classId: options.schedule.classId!, + scheduleId: options.scheduleId, + attendanceSessionId: options.attendanceSessionId, + attendanceDate: options.lessonDate, + session: options.lessonSessionKey, + status: options.status, + source: 'dingtalk', + ...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime), + remark: options.remark, + }); +} + +export async function buildLessonRecord( + recordRepo: Repository, + dingLeaveRawRepo: Repository, + rawByStudent: Map, + classStudent: ClassStudent, + schedule: LessonRecordSchedule, + lessonDate: string, + lessonSessionKey: string, + attendanceSessionId: number, + scheduleId: number, + finalize: boolean, +): Promise { + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(classStudent.studentId) ?? [], + schedule, + lessonDate, + ); + const resolved = await resolveLessonStatus( + dingLeaveRawRepo, + classStudent.studentId, + raw, + schedule, + lessonDate, + finalize, + ); + return createLessonRecord(recordRepo, classStudent, raw, { + schedule, + scheduleId, + lessonDate, + lessonSessionKey, + attendanceSessionId, + status: resolved.status, + remark: resolved.remark, + }); +} + +export async function findApprovedLeaveForStudent( + dingLeaveRawRepo: Repository, + studentId: number, + schedule: LessonScheduleLike, + lessonDate: string, +): Promise { + const leaves = await dingLeaveRawRepo.find({ + where: { matchedStudentId: studentId }, + }); + const window = getLessonAttendanceWindow(schedule, lessonDate); + const overlapping = leaves.filter( + (leave) => + leave.startTime && + leave.endTime && + leave.startTime.getTime() <= window.end && + leave.endTime.getTime() >= window.start, + ); + overlapping.sort( + (left, right) => + (right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0), + ); + return overlapping[0] ?? null; +} diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index ed8d4e6..845239d 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -22,9 +22,9 @@ import { getLessonAttendanceImportDateRange, getLessonAttendanceWindow, selectDingTalkRecordsForLesson, - mapDingTalkStatus, getLessonPunchMetadata, } from './attendance-dingtalk'; +import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status'; @Injectable() export class AttendanceLessonService { @@ -163,7 +163,8 @@ export class AttendanceLessonService { schedule, lessonDate, ); - const resolved = await this.resolveLessonStatus( + const resolved = await resolveLessonStatus( + this.dingLeaveRawRepo, record.studentId, raw, schedule, @@ -183,8 +184,9 @@ export class AttendanceLessonService { for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; updatedRecords.push( - await this.buildLessonRecord( + await buildLessonRecord( recordRepo, + this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, @@ -257,8 +259,9 @@ export class AttendanceLessonService { const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const records = await Promise.all( classStudents.map((classStudent) => - this.buildLessonRecord( + buildLessonRecord( recordRepo, + this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, @@ -281,122 +284,6 @@ export class AttendanceLessonService { }); } - /** - * 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与 - * 本节课时间窗口重叠,则记为 leave,而不是缺勤。 - */ - private async resolveLessonStatus( - studentId: number, - raw: DingAttendanceRaw[], - schedule: Pick, - lessonDate: string, - finalize: boolean, - ): Promise<{ status: string; remark?: string }> { - const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime); - if (!finalize) { - return { - status: mapDingTalkStatus(raw, false), - remark: hasPunch ? undefined : '未获取到钉钉打卡结果', - }; - } - if (hasPunch) return { status: 'present', remark: undefined }; - - const leave = await this.findApprovedLeaveForStudent(studentId, schedule, lessonDate); - if (leave) { - return { - status: 'leave', - remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`, - }; - } - return { status: 'absent', remark: '课程截止仍未打卡' }; - } - - private createLessonRecord( - recordRepo: Repository, - classStudent: ClassStudent, - raw: DingAttendanceRaw[], - options: { - schedule: Pick; - scheduleId: number; - lessonDate: string; - lessonSessionKey: string; - attendanceSessionId: number; - status: string; - remark?: string; - }, - ): AttendanceRecord { - return recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: options.schedule.classId!, - scheduleId: options.scheduleId, - attendanceSessionId: options.attendanceSessionId, - attendanceDate: options.lessonDate, - session: options.lessonSessionKey, - status: options.status, - source: 'dingtalk', - ...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime), - remark: options.remark, - }); - } - - private async buildLessonRecord( - recordRepo: Repository, - rawByStudent: Map, - classStudent: ClassStudent, - schedule: Pick, - lessonDate: string, - lessonSessionKey: string, - attendanceSessionId: number, - scheduleId: number, - finalize: boolean, - ): Promise { - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], - schedule, - lessonDate, - ); - const resolved = await this.resolveLessonStatus( - classStudent.studentId, - raw, - schedule, - lessonDate, - finalize, - ); - return this.createLessonRecord(recordRepo, classStudent, raw, { - schedule, - scheduleId, - lessonDate, - lessonSessionKey, - attendanceSessionId, - status: resolved.status, - remark: resolved.remark, - }); - } - - private async findApprovedLeaveForStudent( - studentId: number, - schedule: Pick, - lessonDate: string, - ): Promise { - const leaves = await this.dingLeaveRawRepo.find({ - where: { matchedStudentId: studentId }, - }); - const window = getLessonAttendanceWindow(schedule, lessonDate); - const overlapping = leaves.filter( - (leave) => - leave.startTime && - leave.endTime && - leave.startTime.getTime() <= window.end && - leave.endTime.getTime() >= window.start, - ); - overlapping.sort( - (left, right) => - (right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0), - ); - return overlapping[0] ?? null; - } - private async fetchDingTalkRawByStudent( classId: number, schedule: Pick, diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index ac84445..eaa0aa8 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -37,6 +37,8 @@ const createService = () => { }; const importService = { importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), + }; + const leaveSyncService = { syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), }; const service = new AttendanceSettlementService( @@ -44,13 +46,14 @@ const createService = () => { sessionRepo as never, attendanceService as never, importService as never, + leaveSyncService as never, ); - return { service, scheduleRepo, sessionRepo, attendanceService, importService }; + return { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService }; }; describe('AttendanceSettlementService', () => { it('pulls and finalizes an ended lesson once', async () => { - const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); @@ -69,7 +72,7 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 2, '2026-07-13', 21, true, ); - expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ + expect(leaveSyncService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ startDate: '2026-07-13', endDate: '2026-07-13', userIds: ['ding-1'], @@ -78,10 +81,10 @@ describe('AttendanceSettlementService', () => { }); it('finalizes the lesson even when the leave sync fails', async () => { - const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const { service, scheduleRepo, sessionRepo, attendanceService, leaveSyncService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); - importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); + leaveSyncService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 7472fae..a3fa8f1 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities'; +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceService } from './attendance.service'; @@ -19,6 +20,7 @@ export class AttendanceSettlementService { private readonly sessionRepo: Repository, private readonly attendanceService: AttendanceService, private readonly importService: AttendanceImportService, + private readonly leaveSyncService: AttendanceLeaveSyncService, ) {} @Cron('* * * * *') @@ -130,7 +132,7 @@ export class AttendanceSettlementService { throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); } try { - await this.importService.syncLeaveStatusForLesson({ + await this.leaveSyncService.syncLeaveStatusForLesson({ ...importRange, userIds, autoMatch: true, diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 348e813..323d161 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceSettlementService } from './attendance-settlement.service'; import { AttendanceController } from './attendance.controller'; @@ -17,7 +18,12 @@ import { IntegrationModule } from '../integration/integration.module'; IntegrationModule, ], controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController], - providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService], + providers: [ + AttendanceService, + AttendanceImportService, + AttendanceLeaveSyncService, + AttendanceSettlementService, + ], exports: [AttendanceService, AttendanceImportService], }) export class AttendanceModule {}