Compare commits
8 Commits
v0.0.1.beta1
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f58572b8f | |||
| a8125cc224 | |||
| 1f519767af | |||
| 8e750311bd | |||
| 4033153037 | |||
| ddcbe65856 | |||
| 994dc360ae | |||
| 01728a4712 |
Generated
+317
@@ -18,6 +18,7 @@
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"md-editor-v3": "^6.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.9.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
@@ -1533,6 +1535,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -1900,6 +1912,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -1989,6 +2025,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -2016,6 +2061,17 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2040,6 +2096,24 @@
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -2097,6 +2171,15 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz",
|
||||
@@ -2122,6 +2205,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2136,6 +2225,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
@@ -2242,6 +2337,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
@@ -2302,6 +2410,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -2403,6 +2520,15 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
||||
@@ -2736,6 +2862,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
@@ -2979,6 +3117,42 @@
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2986,6 +3160,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -3048,6 +3231,15 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -3091,6 +3283,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -3253,6 +3462,21 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -3306,6 +3530,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3324,6 +3554,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
|
||||
@@ -3728,6 +3984,26 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",
|
||||
@@ -3744,6 +4020,12 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
|
||||
@@ -3759,6 +4041,41 @@
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"md-editor-v3": "^6.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.9.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoadingBar from '@/components/common/LoadingBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<LoadingBar />
|
||||
<router-view />
|
||||
<Toaster position="top-center" :duration="3000" rich-colors />
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CpTaskExecuteParams,
|
||||
CpTaskFormItem,
|
||||
CpLeaveReportParams,
|
||||
CpLeaveApplyParams,
|
||||
TaskStatisticsItem,
|
||||
TaskStatisticsV2Item,
|
||||
DashboardSummary,
|
||||
@@ -49,6 +50,10 @@ export const cpUserApi = {
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/authorizeConfirm', data),
|
||||
customDevice: (data: CpUserCustomDeviceParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/customDevice', data),
|
||||
generateQr: () =>
|
||||
request.post<any, ApiResponse<{ uuid: string }>>('/v1/cpdaily/user/generateQr'),
|
||||
checkQr: (uuid: string) =>
|
||||
request.get<any, ApiResponse<{ status: string; deviceCode?: string }>>('/v1/cpdaily/user/checkQr', { params: { uuid } }),
|
||||
}
|
||||
|
||||
// ---- 任务管理 ----
|
||||
@@ -113,4 +118,14 @@ export const cpLeaveApi = {
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/report', data),
|
||||
uploadImage: (formData: FormData) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/upload', formData),
|
||||
checkLeave: (cpUserId: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/cpdaily/leave/check/${cpUserId}`),
|
||||
getLeaveTypes: (cpUserId: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/cpdaily/leave/types/${cpUserId}`),
|
||||
getLeaveTypeCfg: (data: { cpUserId: number; code: number }) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/typeCfg', data),
|
||||
getApproveFlow: (data: { cpUserId: number; leaveType: number; startTime: string; endTime: string }) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/approveFlow', data),
|
||||
apply: (data: CpLeaveApplyParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/apply', data),
|
||||
}
|
||||
|
||||
+7
-1
@@ -71,7 +71,9 @@ request.interceptors.response.use(
|
||||
if (data && typeof data.code === 'number' && data.code !== 200) {
|
||||
const msg = data.message || '请求失败'
|
||||
message.error(msg)
|
||||
return Promise.reject(new Error(msg))
|
||||
const err: any = new Error(msg)
|
||||
err.code = data.code
|
||||
return Promise.reject(err)
|
||||
}
|
||||
return data
|
||||
},
|
||||
@@ -80,6 +82,10 @@ request.interceptors.response.use(
|
||||
localStorage.removeItem('token')
|
||||
const { default: router } = await import('@/router')
|
||||
router.push('/login')
|
||||
} else if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else if (error.message && !error.message.includes('canceled')) {
|
||||
message.error(error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -209,8 +209,8 @@ defineExpose({ refresh })
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<span class="text-sm text-muted-foreground">共 {{ total }} 条</span>
|
||||
<div v-if="total > 0" class="mt-4 flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-muted-foreground whitespace-nowrap">共 {{ total }} 条</span>
|
||||
<Pagination
|
||||
:total="total"
|
||||
:items-per-page="size"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useLoadingBar } from '@/composables/useLoadingBar'
|
||||
|
||||
const { progress, visible, status } = useLoadingBar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="loading-bar">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed top-0 left-0 right-0 z-[9999] h-[2px] pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-all duration-200 ease-out"
|
||||
:class="[
|
||||
status === 'error' ? 'bg-destructive' : 'bg-primary',
|
||||
]"
|
||||
:style="{ width: progress + '%' }"
|
||||
>
|
||||
<div
|
||||
v-if="status === 'loading'"
|
||||
class="absolute right-0 top-0 h-full w-24 opacity-60 blur-sm bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-bar-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.loading-bar-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -61,11 +61,14 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
toast.success(res.message || '登录成功')
|
||||
captchaOpen.value = false
|
||||
await router.push('/')
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一 toast 提示
|
||||
// 重新加载验证码
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} catch (err: any) {
|
||||
// 10003 为验证码错误,刷新验证码继续验证;其他错误关闭弹窗
|
||||
if (err?.code === 10003) {
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} else {
|
||||
captchaOpen.value = false
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const progress = ref(0)
|
||||
const visible = ref(false)
|
||||
const status = ref<'loading' | 'done' | 'error'>('loading')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimer()
|
||||
progress.value = 0
|
||||
status.value = 'loading'
|
||||
visible.value = true
|
||||
// 模拟进度:快速到 80%,然后减速
|
||||
timer = setInterval(() => {
|
||||
if (progress.value < 80) {
|
||||
progress.value += (80 - progress.value) * 0.1
|
||||
} else if (progress.value < 95) {
|
||||
progress.value += 0.5
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
clearTimer()
|
||||
status.value = 'done'
|
||||
progress.value = 100
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
progress.value = 0
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function error() {
|
||||
clearTimer()
|
||||
status.value = 'error'
|
||||
progress.value = 100
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
progress.value = 0
|
||||
}, 800)
|
||||
}
|
||||
|
||||
export function useLoadingBar() {
|
||||
return { progress, visible, status, start, finish, error }
|
||||
}
|
||||
+16
-3
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { generateRoutesFromMenu } from './dynamic-routes'
|
||||
import { useLoadingBar } from '@/composables/useLoadingBar'
|
||||
|
||||
/** 白名单路由:无需登录即可访问 */
|
||||
const whiteList = [
|
||||
@@ -12,6 +13,7 @@ const whiteList = [
|
||||
'/reset-password',
|
||||
'/confirm',
|
||||
'/balance-migrate',
|
||||
'/cpdaily/authorize',
|
||||
]
|
||||
|
||||
/** 标记是否已加载过动态路由(避免重复加载) */
|
||||
@@ -24,7 +26,10 @@ export function resetRouteState() {
|
||||
|
||||
/** 注册全局前置守卫 */
|
||||
export function setupGuards(router: Router) {
|
||||
const loadingBar = useLoadingBar()
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
loadingBar.start()
|
||||
const authStore = useAuthStore()
|
||||
const permStore = usePermissionStore()
|
||||
|
||||
@@ -57,8 +62,8 @@ export function setupGuards(router: Router) {
|
||||
}
|
||||
|
||||
dynamicRoutesLoaded = true
|
||||
// 重新导航以匹配新添加的路由
|
||||
next({ ...to, replace: true })
|
||||
// 重新导航以匹配新添加的路由(用 path 而非展开 to,避免 name 导航跳过子路由匹配)
|
||||
next({ path: to.fullPath, replace: true })
|
||||
} catch {
|
||||
// 权限加载失败 → 清除 token 跳转登录
|
||||
authStore.logout()
|
||||
@@ -70,11 +75,19 @@ export function setupGuards(router: Router) {
|
||||
next()
|
||||
} else {
|
||||
// 未登录 → 白名单放行,其他跳转登录
|
||||
if (whiteList.includes(to.path)) {
|
||||
if (whiteList.some(p => to.path === p || to.path.startsWith(p + '/'))) {
|
||||
next()
|
||||
} else {
|
||||
next('/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => loadingBar.finish(), 200)
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
loadingBar.error()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ export const staticRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: '忘记密码' },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
path: '/reset-password/:token',
|
||||
name: 'resetPassword',
|
||||
component: () => import('@/views/login/ResetPassword.vue'),
|
||||
meta: { title: '重置密码' },
|
||||
},
|
||||
{
|
||||
path: '/confirm',
|
||||
path: '/confirm/:token',
|
||||
name: 'confirmEmail',
|
||||
component: () => import('@/views/login/ConfirmEmail.vue'),
|
||||
meta: { title: '邮箱确认' },
|
||||
@@ -38,6 +38,12 @@ export const staticRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/login/BalanceMigrate.vue'),
|
||||
meta: { title: '积分转移' },
|
||||
},
|
||||
{
|
||||
path: '/cpdaily/authorize/:uuid',
|
||||
name: 'cpDeviceAuthorize',
|
||||
component: () => import('@/views/cpdaily/CpDeviceAuthorize.vue'),
|
||||
meta: { title: '设备授权' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'layout',
|
||||
|
||||
@@ -21,9 +21,9 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
const dynamicRoutes = ref<any[]>([])
|
||||
|
||||
/** 从后端菜单树生成 Vue Router 路由 */
|
||||
function generateRoutes(menuTree: UserMenu[]) {
|
||||
function generateRoutes(menuTree: UserMenu[]): any[] {
|
||||
const modules = import.meta.glob('@/views/**/*.vue')
|
||||
const routes = menuTree
|
||||
const routes: any[] = menuTree
|
||||
.filter(m => !m.hidden && m.type !== 'button')
|
||||
.map(menu => ({
|
||||
path: menu.route,
|
||||
|
||||
@@ -183,3 +183,37 @@ export interface CpUserCustomDeviceParams {
|
||||
mobileCode: string
|
||||
mobileType: 'random' | 'manual'
|
||||
}
|
||||
|
||||
export interface CpLeaveType {
|
||||
id: string
|
||||
code: number
|
||||
name: string
|
||||
mustOut: number
|
||||
reasonWordLimit: number
|
||||
attachmentNumberLimit: number
|
||||
}
|
||||
|
||||
export interface CpLeaveApprover {
|
||||
userWid: string
|
||||
userId: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
export interface CpLeaveApplyParams {
|
||||
cpUserId: number
|
||||
leaveType: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
urgencyMobile: string
|
||||
leaveReason: string
|
||||
outStatus: boolean
|
||||
address: string
|
||||
detailAddress: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
locationAddr: string
|
||||
imageUrl?: string
|
||||
approverWid?: string
|
||||
approverId?: string
|
||||
approverName?: string
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ const onAddSubmit = handleAddSubmit(async (values) => {
|
||||
toast.success('生成卡密成功')
|
||||
addDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '生成卡密失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
@@ -97,8 +97,8 @@ function handleBan(id: number) {
|
||||
await cardApi.ban(id)
|
||||
toast.success('禁用成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '禁用失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { cpUserApi } from '@/api/cpdaily'
|
||||
|
||||
const route = useRoute()
|
||||
const uuid = route.params.uuid as string
|
||||
|
||||
type PageState = 'loading' | 'invalid' | 'not-cpdaily' | 'fetching' | 'success' | 'error'
|
||||
const state = ref<PageState>('loading')
|
||||
const deviceCode = ref('')
|
||||
const errorMsg = ref('')
|
||||
const copied = ref(false)
|
||||
|
||||
function isCpdaily(): boolean {
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
return ua.includes('cpdaily') || ua.includes('wisedu') || ua.includes('campushoy')
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
if (!deviceCode.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(deviceCode.value)
|
||||
} catch {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = deviceCode.value
|
||||
el.style.cssText = 'position:fixed;opacity:0'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function loadCordova(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const isAndroid = /Android|Linux/.test(navigator.userAgent)
|
||||
const src = 'https://static.campushoy.com/innerjs/'
|
||||
+ (isAndroid ? 'cordova_android.js' : 'cordova_ios.js')
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject(new Error('脚本加载失败'))
|
||||
document.head.appendChild(s)
|
||||
if (!(window as any).mamp) (window as any).mamp = {}
|
||||
;(window as any).mamp.initEnv = (cb: () => void) =>
|
||||
document.addEventListener('deviceready', cb, false)
|
||||
})
|
||||
}
|
||||
|
||||
function waitCordova(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let n = 0
|
||||
const t = setInterval(() => {
|
||||
if ((window as any).cordova?.exec) {
|
||||
clearInterval(t)
|
||||
resolve()
|
||||
} else if (++n > 600) {
|
||||
clearInterval(t)
|
||||
reject(new Error('Cordova 加载超时'))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
function getDeviceUuid(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
;(window as any).cordova.exec(
|
||||
(res: any) => resolve(res.uuid),
|
||||
(err: any) => reject(new Error(err?.message || String(err))),
|
||||
'MAMPDevice', 'getDeviceInfo', []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 验证UUID有效性
|
||||
try {
|
||||
await cpUserApi.checkQr(uuid)
|
||||
} catch {
|
||||
state.value = 'invalid'
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检测是否在今日校园App内
|
||||
if (!isCpdaily()) {
|
||||
state.value = 'not-cpdaily'
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 加载cordova获取设备码
|
||||
state.value = 'fetching'
|
||||
try {
|
||||
await loadCordova()
|
||||
await waitCordova()
|
||||
deviceCode.value = await getDeviceUuid()
|
||||
state.value = 'success'
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.message || '未知错误'
|
||||
state.value = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="authorize-page">
|
||||
<div class="aurora-orb aurora-orb-1" />
|
||||
<div class="aurora-orb aurora-orb-2" />
|
||||
<div class="container">
|
||||
<div class="glass-card">
|
||||
<!-- 加载中 -->
|
||||
<template v-if="state === 'loading'">
|
||||
<div class="icon-wrap icon-loading">📱</div>
|
||||
<h1>正在验证...</h1>
|
||||
<p class="desc">请稍候</p>
|
||||
<div class="spinner" />
|
||||
</template>
|
||||
|
||||
<!-- 二维码无效 -->
|
||||
<template v-else-if="state === 'invalid'">
|
||||
<div class="icon-wrap icon-error">✕</div>
|
||||
<h1>二维码无效</h1>
|
||||
<p class="err-text">二维码无效或已过期,请重新获取</p>
|
||||
</template>
|
||||
|
||||
<!-- 非今日校园App -->
|
||||
<template v-else-if="state === 'not-cpdaily'">
|
||||
<div class="icon-wrap icon-warn">📵</div>
|
||||
<h1>请用今日校园扫码</h1>
|
||||
<p class="desc">此页面需要在<strong>今日校园 App</strong>内打开才能获取设备码</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">打开今日校园 App<span>在手机上启动今日校园</span></div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">点击右上角扫一扫<span>使用 App 内置扫码功能</span></div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">扫描电脑上的二维码<span>扫码后设备码将自动显示</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 正在获取设备码 -->
|
||||
<template v-else-if="state === 'fetching'">
|
||||
<div class="icon-wrap icon-loading">📱</div>
|
||||
<h1>正在获取设备码</h1>
|
||||
<p class="desc">请稍候,正在读取设备信息...</p>
|
||||
<div class="spinner" />
|
||||
</template>
|
||||
|
||||
<!-- 获取成功 -->
|
||||
<template v-else-if="state === 'success'">
|
||||
<div class="icon-wrap icon-success">✓</div>
|
||||
<h1>获取成功</h1>
|
||||
<p class="desc">已成功获取设备码,请复制后转发给系统管理员</p>
|
||||
<div class="result-section">
|
||||
<div class="result-label">设备码</div>
|
||||
<div class="code-box">{{ deviceCode }}</div>
|
||||
<button class="btn-primary" @click="copyCode">
|
||||
<span>📋</span>
|
||||
<span>{{ copied ? '已复制 ✓' : '复制设备码' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="footer-note">请及时复制,关闭页面后需重新扫码获取</p>
|
||||
</template>
|
||||
|
||||
<!-- 获取失败 -->
|
||||
<template v-else-if="state === 'error'">
|
||||
<div class="icon-wrap icon-error">✕</div>
|
||||
<h1>获取失败</h1>
|
||||
<p class="err-text">{{ errorMsg }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.authorize-page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
background: #F5F5F7;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.aurora-orb {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
.aurora-orb-1 {
|
||||
width: 60vw; height: 60vw;
|
||||
background: radial-gradient(circle, rgba(162,210,255,0.4), transparent);
|
||||
top: -20%; left: -10%;
|
||||
}
|
||||
.aurora-orb-2 {
|
||||
width: 55vw; height: 55vw;
|
||||
background: radial-gradient(circle, rgba(200,180,255,0.3), transparent);
|
||||
bottom: -20%; right: -10%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
@keyframes float {
|
||||
0%,100% { transform: translate(0,0) scale(1); }
|
||||
50% { transform: translate(30px,-30px) scale(1.1); }
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 20px;
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||
padding: 36px 28px 32px;
|
||||
text-align: center;
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.25,1,0.5,1) 0.05s backwards;
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.icon-wrap {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 36px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.icon-loading { background: linear-gradient(135deg,#0071e3,#40a9ff); box-shadow: 0 4px 16px rgba(0,113,227,0.3); }
|
||||
.icon-warn { background: linear-gradient(135deg,#ff9f0a,#ffcc02); box-shadow: 0 4px 16px rgba(255,159,10,0.3); }
|
||||
.icon-success { background: linear-gradient(135deg,#34c759,#30d158); box-shadow: 0 4px 16px rgba(52,199,89,0.3); }
|
||||
.icon-error { background: linear-gradient(135deg,#ff3b30,#ff6961); box-shadow: 0 4px 16px rgba(255,59,48,0.3); }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: #1d1d1f; margin-bottom: 10px; }
|
||||
.desc { font-size: 15px; color: #86868b; line-height: 1.6; }
|
||||
.desc strong { color: #1d1d1f; font-weight: 600; }
|
||||
.spinner {
|
||||
width: 36px; height: 36px;
|
||||
border: 3px solid rgba(0,113,227,0.15);
|
||||
border-top-color: #0071e3;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.steps { margin: 20px 0 0; display: flex; flex-direction: column; gap: 10px; text-align: left; }
|
||||
.step {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
background: rgba(0,0,0,0.03); border-radius: 12px;
|
||||
padding: 12px 14px; border: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
.step-num {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #0071e3; color: white;
|
||||
font-size: 12px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; margin-top: 1px;
|
||||
}
|
||||
.step-text { font-size: 14px; color: #1d1d1f; line-height: 1.5; }
|
||||
.step-text span { color: #86868b; font-size: 13px; display: block; margin-top: 2px; }
|
||||
.err-text { font-size: 15px; color: #d70015; margin-top: 16px; line-height: 1.5; }
|
||||
.result-section {
|
||||
margin-top: 20px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.result-label {
|
||||
font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.07em; text-transform: uppercase;
|
||||
color: #86868b; text-align: left;
|
||||
}
|
||||
.code-box {
|
||||
background: #F2F2F7; border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 13px; color: #1d1d1f;
|
||||
word-break: break-all; line-height: 1.7;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
user-select: all; text-align: left;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0071e3; color: white;
|
||||
padding: 13px 24px; border-radius: 980px; border: none;
|
||||
font-size: 15px; font-weight: 500; cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.25,1,0.5,1);
|
||||
width: 100%;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.btn-primary:hover { transform: scale(1.02); box-shadow: 0 8px 24px rgba(0,113,227,0.3); filter: brightness(1.08); }
|
||||
.btn-primary:active { transform: scale(0.98); }
|
||||
.footer-note {
|
||||
text-align: center; font-size: 13px; color: #86868b;
|
||||
margin-top: 16px; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, watch } from 'vue'
|
||||
import { ref, h, watch, computed } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { ClipboardCheck, Upload, X, Eye, Loader2 } from 'lucide-vue-next'
|
||||
import { ClipboardCheck, Upload, X, Eye, Loader2, CalendarPlus } from 'lucide-vue-next'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import type { SearchField } from '@/components/common/DataTable.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -11,6 +11,15 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,7 +28,7 @@ import {
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cpLeaveApi, cpUserApi } from '@/api/cpdaily'
|
||||
import type { CpLeave, CpLeaveReportParams } from '@/types/cpdaily'
|
||||
import type { CpLeave, CpLeaveReportParams, CpLeaveApplyParams, CpLeaveType, CpLeaveApprover } from '@/types/cpdaily'
|
||||
import type { PageParams, PageResponse } from '@/types/api'
|
||||
import { extractPageData } from '@/types/api'
|
||||
|
||||
@@ -49,6 +58,12 @@ function getLeaveStatusInfo(status: number) {
|
||||
return systemLeaveStatusMap[status] || { label: '未知', color: '#909399' }
|
||||
}
|
||||
|
||||
// ---- 类型映射 ----
|
||||
const typeMap: Record<string, string> = {
|
||||
report: '销假',
|
||||
apply: '请假',
|
||||
}
|
||||
|
||||
// ---- 表格列定义 ----
|
||||
const columns: ColumnDef<CpLeave, any>[] = [
|
||||
{
|
||||
@@ -60,6 +75,15 @@ const columns: ColumnDef<CpLeave, any>[] = [
|
||||
},
|
||||
},
|
||||
{ accessorKey: 'title', header: '标题' },
|
||||
{
|
||||
id: 'type',
|
||||
header: '类型',
|
||||
cell: ({ row }) => {
|
||||
const d = row.original as any
|
||||
const t = d.type || 'report'
|
||||
return h(Badge, { variant: t === 'apply' ? 'secondary' : 'outline' }, () => typeMap[t] || t)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '状态',
|
||||
@@ -107,9 +131,9 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpLeave>> => {
|
||||
return extractPageData(res)
|
||||
}
|
||||
|
||||
// ---- 执行销假弹窗(两步流程) ----
|
||||
// ==================== 执行销假弹窗(两步流程) ====================
|
||||
const reportDialogOpen = ref(false)
|
||||
const reportStep = ref<1 | 2 | 3>(1) // 1=选用户 2=选请假记录 3=填写销假信息
|
||||
const reportStep = ref<1 | 2 | 3>(1)
|
||||
const reportLoading = ref(false)
|
||||
|
||||
// 步骤1:选择用户
|
||||
@@ -135,7 +159,7 @@ const reportForm = ref<CpLeaveReportParams>({
|
||||
imageUrl: '',
|
||||
})
|
||||
|
||||
// 图片上传
|
||||
// 图片上传(销假)
|
||||
const uploadLoading = ref(false)
|
||||
const uploadedImageUrl = ref('')
|
||||
const previewOpen = ref(false)
|
||||
@@ -198,8 +222,8 @@ async function handleFetchSystemLeaves() {
|
||||
} else {
|
||||
reportStep.value = 2
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '获取请假列表失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
systemLeavesLoading.value = false
|
||||
}
|
||||
@@ -265,8 +289,8 @@ async function handleImageUpload(event: Event) {
|
||||
} else {
|
||||
toast.error('上传失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '图片上传失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
input.value = ''
|
||||
@@ -294,22 +318,328 @@ async function handleReport() {
|
||||
toast.success('销假成功')
|
||||
reportDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '销假失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
reportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 请假弹窗 ====================
|
||||
const applyDialogOpen = ref(false)
|
||||
const applyStep = ref<1 | 2>(1) // 1=选用户 2=填写表单
|
||||
const applyLoading = ref(false)
|
||||
|
||||
// 步骤1:选择用户
|
||||
const applyUserSearchText = ref('')
|
||||
const applyUserResults = ref<{ label: string; value: number }[]>([])
|
||||
const applyUserSearching = ref(false)
|
||||
const applySelectedUserId = ref<number | null>(null)
|
||||
let applyUserTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const applyCheckLoading = ref(false)
|
||||
|
||||
// 步骤2:请假表单
|
||||
const leaveTypes = ref<CpLeaveType[]>([])
|
||||
const leaveTypesLoading = ref(false)
|
||||
const selectedLeaveTypeCode = ref<string>('')
|
||||
const needApproval = ref(false)
|
||||
const approvers = ref<CpLeaveApprover[]>([])
|
||||
const approversLoading = ref(false)
|
||||
const selectedApproverWid = ref<string>('')
|
||||
|
||||
// 请假图片上传
|
||||
const applyUploadLoading = ref(false)
|
||||
const applyUploadedImageUrl = ref('')
|
||||
|
||||
const applyForm = ref<CpLeaveApplyParams>({
|
||||
cpUserId: 0,
|
||||
leaveType: 0,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
urgencyMobile: '',
|
||||
leaveReason: '',
|
||||
outStatus: false,
|
||||
address: '',
|
||||
detailAddress: '',
|
||||
longitude: '',
|
||||
latitude: '',
|
||||
locationAddr: '',
|
||||
imageUrl: '',
|
||||
approverWid: '',
|
||||
approverId: '',
|
||||
approverName: '',
|
||||
})
|
||||
|
||||
// 日期时间拆分
|
||||
const applyStartDate = ref('')
|
||||
const applyStartTime = ref('')
|
||||
const applyEndDate = ref('')
|
||||
const applyEndTime = ref('')
|
||||
|
||||
const currentLeaveType = computed(() => {
|
||||
if (!selectedLeaveTypeCode.value) return null
|
||||
return leaveTypes.value.find(t => String(t.code) === selectedLeaveTypeCode.value) || null
|
||||
})
|
||||
|
||||
watch(applyUserSearchText, (val) => {
|
||||
if (applyUserTimer) clearTimeout(applyUserTimer)
|
||||
if (!val || val.length < 1) {
|
||||
applyUserResults.value = []
|
||||
return
|
||||
}
|
||||
if (applySelectedUserId.value && applyUserResults.value.length === 0) return
|
||||
applyUserTimer = setTimeout(async () => {
|
||||
applyUserSearching.value = true
|
||||
try {
|
||||
const res = await cpUserApi.getList({ page: 1, size: 10, username: val })
|
||||
const data = res.data as any
|
||||
const list = Array.isArray(data?.pageData) ? data.pageData : Array.isArray(data) ? data : []
|
||||
applyUserResults.value = list.map((u: any) => ({
|
||||
label: `${u.username}${u.edges?.school?.name ? ' - ' + u.edges.school.name : ''}`,
|
||||
value: u.id,
|
||||
}))
|
||||
} catch {
|
||||
applyUserResults.value = []
|
||||
} finally {
|
||||
applyUserSearching.value = false
|
||||
}
|
||||
}, 400)
|
||||
})
|
||||
|
||||
function selectApplyUser(user: { label: string; value: number }) {
|
||||
applySelectedUserId.value = user.value
|
||||
applyUserSearchText.value = user.label
|
||||
applyUserResults.value = []
|
||||
}
|
||||
|
||||
function openApplyDialog() {
|
||||
applyStep.value = 1
|
||||
applySelectedUserId.value = null
|
||||
applyUserSearchText.value = ''
|
||||
applyUserResults.value = []
|
||||
leaveTypes.value = []
|
||||
selectedLeaveTypeCode.value = ''
|
||||
needApproval.value = false
|
||||
approvers.value = []
|
||||
selectedApproverWid.value = ''
|
||||
applyUploadedImageUrl.value = ''
|
||||
applyStartDate.value = ''
|
||||
applyStartTime.value = ''
|
||||
applyEndDate.value = ''
|
||||
applyEndTime.value = ''
|
||||
applyForm.value = {
|
||||
cpUserId: 0, leaveType: 0, startTime: '', endTime: '',
|
||||
urgencyMobile: '', leaveReason: '', outStatus: false,
|
||||
address: '', detailAddress: '', longitude: '', latitude: '',
|
||||
locationAddr: '', imageUrl: '', approverWid: '', approverId: '', approverName: '',
|
||||
}
|
||||
applyDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleCheckAndFetchTypes() {
|
||||
if (!applySelectedUserId.value) {
|
||||
toast.error('请选择用户')
|
||||
return
|
||||
}
|
||||
applyCheckLoading.value = true
|
||||
try {
|
||||
// 检查是否允许请假
|
||||
await cpLeaveApi.checkLeave(applySelectedUserId.value)
|
||||
// 获取请假类型
|
||||
leaveTypesLoading.value = true
|
||||
const res = await cpLeaveApi.getLeaveTypes(applySelectedUserId.value)
|
||||
const data = res.data
|
||||
leaveTypes.value = Array.isArray(data) ? data : []
|
||||
if (leaveTypes.value.length === 0) {
|
||||
toast.info('未获取到请假类型')
|
||||
return
|
||||
}
|
||||
applyForm.value.cpUserId = applySelectedUserId.value
|
||||
applyStep.value = 2
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
applyCheckLoading.value = false
|
||||
leaveTypesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择请假类型后的处理
|
||||
watch(selectedLeaveTypeCode, async (val) => {
|
||||
if (!val) return
|
||||
const lt = leaveTypes.value.find(t => String(t.code) === val)
|
||||
if (!lt) return
|
||||
applyForm.value.leaveType = lt.code
|
||||
// mustOut=1 时强制离校
|
||||
if (Number(lt.mustOut) === 1) {
|
||||
applyForm.value.outStatus = true
|
||||
} else {
|
||||
applyForm.value.outStatus = false
|
||||
}
|
||||
// 查询是否需要审批
|
||||
needApproval.value = false
|
||||
approvers.value = []
|
||||
selectedApproverWid.value = ''
|
||||
try {
|
||||
const res = await cpLeaveApi.getLeaveTypeCfg({ cpUserId: applySelectedUserId.value!, code: lt.code })
|
||||
const cfg = res.data
|
||||
if (cfg && cfg.needApproval === 1) {
|
||||
needApproval.value = true
|
||||
// 如果时间已填,自动获取审批人
|
||||
tryAutoFetchApprovers()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
// 时间变化时自动获取审批人
|
||||
watch([applyStartDate, applyStartTime, applyEndDate, applyEndTime], () => {
|
||||
if (needApproval.value) {
|
||||
tryAutoFetchApprovers()
|
||||
}
|
||||
})
|
||||
|
||||
function tryAutoFetchApprovers() {
|
||||
const startTime = buildDateTime(applyStartDate.value, applyStartTime.value)
|
||||
const endTime = buildDateTime(applyEndDate.value, applyEndTime.value)
|
||||
if (startTime && endTime && applyForm.value.leaveType) {
|
||||
handleFetchApprovers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchApprovers() {
|
||||
if (!applyForm.value.leaveType) {
|
||||
toast.error('请选择请假类型')
|
||||
return
|
||||
}
|
||||
const startTime = buildDateTime(applyStartDate.value, applyStartTime.value)
|
||||
const endTime = buildDateTime(applyEndDate.value, applyEndTime.value)
|
||||
if (!startTime || !endTime) {
|
||||
toast.error('请填写开始和结束时间')
|
||||
return
|
||||
}
|
||||
approversLoading.value = true
|
||||
try {
|
||||
const res = await cpLeaveApi.getApproveFlow({
|
||||
cpUserId: applySelectedUserId.value!,
|
||||
leaveType: applyForm.value.leaveType,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
const data = res.data
|
||||
// 审批人可能在 approveFlow 或直接是数组
|
||||
const flowList = data?.approveFlow || data?.approvers || (Array.isArray(data) ? data : [])
|
||||
approvers.value = Array.isArray(flowList) ? flowList : []
|
||||
if (approvers.value.length === 0) {
|
||||
toast.info('未获取到审批人')
|
||||
}
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
approversLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateTime(date: string, time: string): string {
|
||||
if (!date) return ''
|
||||
return `${date} ${time || '00:00'}:00`
|
||||
}
|
||||
|
||||
// 请假图片上传
|
||||
async function handleApplyImageUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
applyUploadLoading.value = true
|
||||
try {
|
||||
const compressed = await compressImage(file)
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressed)
|
||||
formData.append('cpUserId', String(applySelectedUserId.value))
|
||||
const res = await cpLeaveApi.uploadImage(formData)
|
||||
const url = res.data?.url || res.data?.previewUrl || (typeof res.data === 'string' ? res.data : '')
|
||||
if (url) {
|
||||
applyUploadedImageUrl.value = url
|
||||
applyForm.value.imageUrl = url
|
||||
toast.success('图片上传成功')
|
||||
} else {
|
||||
toast.error('上传失败')
|
||||
}
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
applyUploadLoading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeApplyImage() {
|
||||
applyUploadedImageUrl.value = ''
|
||||
applyForm.value.imageUrl = ''
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const f = applyForm.value
|
||||
if (!f.leaveType) { toast.error('请选择请假类型'); return }
|
||||
const startTime = buildDateTime(applyStartDate.value, applyStartTime.value)
|
||||
const endTime = buildDateTime(applyEndDate.value, applyEndTime.value)
|
||||
if (!startTime || !endTime) { toast.error('请填写开始和结束时间'); return }
|
||||
if (!f.urgencyMobile) { toast.error('请填写紧急联系人手机号'); return }
|
||||
if (!f.leaveReason) { toast.error('请填写请假原因'); return }
|
||||
// 检查字数限制
|
||||
const lt = currentLeaveType.value
|
||||
if (lt && lt.reasonWordLimit > 0 && f.leaveReason.length < lt.reasonWordLimit) {
|
||||
toast.error(`请假原因至少 ${lt.reasonWordLimit} 字`)
|
||||
return
|
||||
}
|
||||
if (!f.address) { toast.error('请填写目的地'); return }
|
||||
if (!f.detailAddress) { toast.error('请填写详细地址'); return }
|
||||
if (!f.longitude || !f.latitude) { toast.error('请填写经纬度'); return }
|
||||
if (!f.locationAddr) { toast.error('请填写定位地址'); return }
|
||||
if (needApproval.value && !selectedApproverWid.value) { toast.error('请选择审批人'); return }
|
||||
|
||||
// 填充审批人
|
||||
if (selectedApproverWid.value) {
|
||||
const approver = approvers.value.find(a => a.userWid === selectedApproverWid.value)
|
||||
if (approver) {
|
||||
f.approverWid = approver.userWid
|
||||
f.approverId = approver.userId
|
||||
f.approverName = approver.userName
|
||||
}
|
||||
}
|
||||
|
||||
f.startTime = startTime
|
||||
f.endTime = endTime
|
||||
|
||||
applyLoading.value = true
|
||||
try {
|
||||
await cpLeaveApi.apply(f)
|
||||
toast.success('请假申请提交成功')
|
||||
applyDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
applyLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold tracking-tight">请假销假管理</h2>
|
||||
<Button size="sm" @click="openReportDialog">
|
||||
<ClipboardCheck class="mr-1 h-4 w-4" />
|
||||
执行销假
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="outline" @click="openApplyDialog">
|
||||
<CalendarPlus class="mr-1 h-4 w-4" />
|
||||
请假
|
||||
</Button>
|
||||
<Button size="sm" @click="openReportDialog">
|
||||
<ClipboardCheck class="mr-1 h-4 w-4" />
|
||||
执行销假
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -319,7 +649,7 @@ async function handleReport() {
|
||||
:search-fields="searchFields"
|
||||
/>
|
||||
|
||||
<!-- 执行销假弹窗 -->
|
||||
<!-- ==================== 执行销假弹窗 ==================== -->
|
||||
<Dialog v-model:open="reportDialogOpen">
|
||||
<DialogContent class="w-[calc(100vw-2rem)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
@@ -375,7 +705,6 @@ async function handleReport() {
|
||||
@click="selectLeave(leave)"
|
||||
>
|
||||
<CardContent class="p-3 space-y-2">
|
||||
<!-- 类型标签 + 状态 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="outline" class="text-xs">{{ leave.leaveType || '请假' }}</Badge>
|
||||
<span
|
||||
@@ -383,13 +712,10 @@ async function handleReport() {
|
||||
:style="{ backgroundColor: getLeaveStatusInfo(leave.status).color }"
|
||||
>{{ getLeaveStatusInfo(leave.status).label }}</span>
|
||||
</div>
|
||||
<!-- 原因(允许换行) -->
|
||||
<p class="text-sm break-all line-clamp-2">{{ leave.reason || leave.title || '无' }}</p>
|
||||
<!-- 图片 -->
|
||||
<div v-if="leave.imageUrl || leave.image" class="size-14 rounded-md overflow-hidden border">
|
||||
<img :src="leave.imageUrl || leave.image" class="size-full object-cover" alt="" />
|
||||
</div>
|
||||
<!-- 时间 -->
|
||||
<div class="text-xs text-muted-foreground space-y-0.5">
|
||||
<div>创建:{{ leave.createTime || '-' }}</div>
|
||||
<div>结束:{{ leave.aceEndTime || '-' }}</div>
|
||||
@@ -404,7 +730,6 @@ async function handleReport() {
|
||||
|
||||
<!-- 步骤3:填写销假信息 -->
|
||||
<div v-show="reportStep === 3" class="space-y-4">
|
||||
<!-- 已选请假记录摘要 -->
|
||||
<div v-if="selectedLeave" class="rounded-md border p-3 bg-muted/30 text-sm space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="text-xs">{{ selectedLeave.leaveType || '请假' }}</Badge>
|
||||
@@ -414,9 +739,7 @@ async function handleReport() {
|
||||
预计结束:{{ selectedLeave.aceEndTime || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>地址</Label>
|
||||
<Input v-model="reportForm.address" placeholder="请输入地址" />
|
||||
@@ -434,7 +757,6 @@ async function handleReport() {
|
||||
<div class="space-y-2">
|
||||
<Label>销假图片</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- 已上传图片 -->
|
||||
<div v-if="uploadedImageUrl" class="relative group size-20 rounded-md border overflow-hidden">
|
||||
<img :src="uploadedImageUrl" class="size-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
||||
@@ -442,7 +764,6 @@ async function handleReport() {
|
||||
<button type="button" class="p-1 text-white" @click="removeUploadedImage"><X class="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 上传按钮 -->
|
||||
<label v-if="!uploadedImageUrl" class="size-20 rounded-md border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:border-primary transition-colors text-muted-foreground">
|
||||
<Loader2 v-if="uploadLoading" class="h-5 w-5 animate-spin" />
|
||||
<template v-else>
|
||||
@@ -453,7 +774,6 @@ async function handleReport() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-2">
|
||||
<Button variant="outline" @click="reportStep = 2">上一步</Button>
|
||||
<Button :disabled="reportLoading || !selectedLeave" @click="handleReport">
|
||||
@@ -465,6 +785,193 @@ async function handleReport() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- ==================== 请假弹窗 ==================== -->
|
||||
<Dialog v-model:open="applyDialogOpen">
|
||||
<DialogContent class="w-[calc(100vw-2rem)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>请假申请</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ applyStep === 1 ? '选择用户' : '填写请假信息' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- 步骤指示 -->
|
||||
<div class="flex items-center gap-2 text-sm mb-2">
|
||||
<span :class="applyStep === 1 ? 'text-primary font-medium' : 'text-muted-foreground'">① 选择用户</span>
|
||||
<span class="text-muted-foreground">→</span>
|
||||
<span :class="applyStep === 2 ? 'text-primary font-medium' : 'text-muted-foreground'">② 填写表单</span>
|
||||
</div>
|
||||
|
||||
<!-- 步骤1:选择用户 -->
|
||||
<div v-show="applyStep === 1" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>选择用户</Label>
|
||||
<Input v-model="applyUserSearchText" placeholder="输入用户名搜索" />
|
||||
<div v-if="applyUserSearching" class="text-sm text-muted-foreground">搜索中...</div>
|
||||
<div v-if="applyUserResults.length > 0" class="max-h-32 overflow-y-auto rounded-md border">
|
||||
<div
|
||||
v-for="u in applyUserResults"
|
||||
:key="u.value"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-accent"
|
||||
@click="selectApplyUser(u)"
|
||||
>
|
||||
{{ u.label }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="applySelectedUserId" class="text-sm text-primary">已选择:{{ applyUserSearchText }}</p>
|
||||
</div>
|
||||
<div class="flex justify-center pt-2">
|
||||
<Button :disabled="applyCheckLoading || !applySelectedUserId" @click="handleCheckAndFetchTypes">
|
||||
<Loader2 v-if="applyCheckLoading" class="mr-1 h-4 w-4 animate-spin" />
|
||||
{{ applyCheckLoading ? '检查中...' : '下一步' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2:填写请假表单 -->
|
||||
<div v-show="applyStep === 2" class="space-y-4">
|
||||
<!-- 请假类型 -->
|
||||
<div class="space-y-2">
|
||||
<Label>请假类型</Label>
|
||||
<Select v-model="selectedLeaveTypeCode">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="请选择请假类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="lt in leaveTypes" :key="lt.code" :value="String(lt.code)">
|
||||
{{ lt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 开始时间 -->
|
||||
<div class="space-y-2">
|
||||
<Label>开始时间</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input type="date" v-model="applyStartDate" />
|
||||
<Input type="time" v-model="applyStartTime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结束时间 -->
|
||||
<div class="space-y-2">
|
||||
<Label>结束时间</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input type="date" v-model="applyEndDate" />
|
||||
<Input type="time" v-model="applyEndTime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 是否离校 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>是否离校</Label>
|
||||
<Switch
|
||||
:checked="applyForm.outStatus"
|
||||
:disabled="Number(currentLeaveType?.mustOut) === 1"
|
||||
@update:checked="(val: boolean) => applyForm.outStatus = val"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="Number(currentLeaveType?.mustOut) === 1" class="text-xs text-muted-foreground">该请假类型要求必须离校</p>
|
||||
|
||||
<!-- 紧急联系人手机号 -->
|
||||
<div class="space-y-2">
|
||||
<Label>紧急联系人手机号</Label>
|
||||
<Input v-model="applyForm.urgencyMobile" placeholder="请输入手机号" />
|
||||
</div>
|
||||
|
||||
<!-- 目的地 -->
|
||||
<div class="space-y-2">
|
||||
<Label>目的地</Label>
|
||||
<Input v-model="applyForm.address" placeholder="格式:xx省/xx市/xx区" />
|
||||
</div>
|
||||
|
||||
<!-- 详细地址 -->
|
||||
<div class="space-y-2">
|
||||
<Label>详细地址</Label>
|
||||
<Input v-model="applyForm.detailAddress" placeholder="请输入详细地址" />
|
||||
</div>
|
||||
|
||||
<!-- 请假原因 -->
|
||||
<div class="space-y-2">
|
||||
<Label>
|
||||
请假原因
|
||||
<span v-if="currentLeaveType?.reasonWordLimit" class="text-xs text-muted-foreground ml-1">
|
||||
(最少 {{ currentLeaveType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea v-model="applyForm.leaveReason" placeholder="请输入请假原因" :rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- 经纬度和定位地址 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label>经度</Label>
|
||||
<Input v-model="applyForm.longitude" placeholder="如:116.397" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>纬度</Label>
|
||||
<Input v-model="applyForm.latitude" placeholder="如:39.908" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>定位地址</Label>
|
||||
<Input v-model="applyForm.locationAddr" placeholder="请输入定位地址" />
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div class="space-y-2">
|
||||
<Label>图片附件(可选)</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-if="applyUploadedImageUrl" class="relative group size-20 rounded-md border overflow-hidden">
|
||||
<img :src="applyUploadedImageUrl" class="size-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
||||
<button type="button" class="p-1 text-white" @click="openPreview(applyUploadedImageUrl)"><Eye class="h-4 w-4" /></button>
|
||||
<button type="button" class="p-1 text-white" @click="removeApplyImage"><X class="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="!applyUploadedImageUrl" class="size-20 rounded-md border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:border-primary transition-colors text-muted-foreground">
|
||||
<Loader2 v-if="applyUploadLoading" class="h-5 w-5 animate-spin" />
|
||||
<template v-else>
|
||||
<Upload class="h-5 w-5" />
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</template>
|
||||
<input type="file" class="hidden" accept="image/*" :disabled="applyUploadLoading" @change="handleApplyImageUpload" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批人(需要审批时显示) -->
|
||||
<div v-if="needApproval" class="space-y-2">
|
||||
<Label>
|
||||
审批人
|
||||
<Loader2 v-if="approversLoading" class="inline h-3 w-3 animate-spin ml-1" />
|
||||
</Label>
|
||||
<Select v-if="approvers.length > 0" v-model="selectedApproverWid">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择审批人" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="a in approvers" :key="a.userWid" :value="a.userWid">
|
||||
{{ a.userName }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-else-if="!approversLoading" class="text-xs text-muted-foreground">请先选择请假类型并填写开始和结束时间</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between pt-2">
|
||||
<Button variant="outline" @click="applyStep = 1">上一步</Button>
|
||||
<Button :disabled="applyLoading" @click="handleApply">
|
||||
<Loader2 v-if="applyLoading" class="mr-1 h-4 w-4 animate-spin" />
|
||||
{{ applyLoading ? '提交中...' : '提交请假' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<Dialog v-model:open="previewOpen">
|
||||
<DialogContent class="sm:max-w-lg p-2">
|
||||
|
||||
@@ -198,7 +198,7 @@ async function handleImageUpload(field: CpTaskFormItem, event: Event) {
|
||||
list[idx] = { ...list[idx], url: previewUrl, previewUrl, status: 'finished' }
|
||||
updateImageContent(field)
|
||||
} else { removeImage(field, id); toast.error('上传失败') }
|
||||
} catch { removeImage(field, id); toast.error('上传失败') }
|
||||
} catch { removeImage(field, id) }
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
@@ -244,9 +244,6 @@ function isDaySelected(day: number): boolean {
|
||||
return Array.isArray(executeParams.value.execute_days) && executeParams.value.execute_days.includes(day)
|
||||
}
|
||||
|
||||
const allDaysSelected = computed(() =>
|
||||
Array.isArray(executeParams.value.execute_days) && weekDays.every(d => executeParams.value.execute_days.includes(d.value))
|
||||
)
|
||||
|
||||
// ---- 数据加载 ----
|
||||
async function loadForm() {
|
||||
@@ -265,8 +262,8 @@ async function loadForm() {
|
||||
}
|
||||
if (field.type === 4) initImageField(field)
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '获取表单数据失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { formLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -284,8 +281,8 @@ async function loadExecute() {
|
||||
execute_hour: data.execute_hour ?? 8, execute_minute: data.execute_minute ?? 0,
|
||||
execute_days: Array.isArray(data.execute_days) && data.execute_days.length ? data.execute_days : [1, 2, 3, 4, 5, 6, 7],
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '获取执行参数失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { executeLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -301,8 +298,8 @@ async function saveForm() {
|
||||
const formData = formItems.value.map(f => ({ ...f, content: f.content || '', otherValue: f.otherValue || '' }))
|
||||
await cpTaskApi.updateForm(taskId.value, formData)
|
||||
toast.success('表单保存成功')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '保存表单失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { savingForm.value = false }
|
||||
}
|
||||
|
||||
@@ -317,8 +314,8 @@ async function saveExecute() {
|
||||
execute_days: executeParams.value.execute_days,
|
||||
})
|
||||
toast.success('执行参数保存成功')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '保存执行参数失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { savingExecute.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, watch } from 'vue'
|
||||
import { ref, h, watch, onMounted } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { PlusCircle, Play, Pause, Trash2, Pencil, ScrollText } from 'lucide-vue-next'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import type { SearchField } from '@/components/common/DataTable.vue'
|
||||
@@ -25,6 +25,7 @@ import type { PageParams, PageResponse } from '@/types/api'
|
||||
import { extractPageData } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tableRef = ref<{ refresh: () => void } | null>(null)
|
||||
|
||||
// ---- 确认弹窗状态 ----
|
||||
@@ -59,9 +60,14 @@ const userSearchText = ref('')
|
||||
const userSearchResults = ref<{ label: string; value: number }[]>([])
|
||||
const userSearching = ref(false)
|
||||
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let skipNextSearch = false
|
||||
|
||||
watch(userSearchText, (val) => {
|
||||
if (userSearchTimer) clearTimeout(userSearchTimer)
|
||||
if (skipNextSearch) {
|
||||
skipNextSearch = false
|
||||
return
|
||||
}
|
||||
if (!val || val.length < 1) {
|
||||
userSearchResults.value = []
|
||||
return
|
||||
@@ -86,6 +92,7 @@ watch(userSearchText, (val) => {
|
||||
|
||||
function selectUser(user: { label: string; value: number }) {
|
||||
addUserId.value = user.value
|
||||
skipNextSearch = true
|
||||
userSearchText.value = user.label
|
||||
userSearchResults.value = []
|
||||
}
|
||||
@@ -139,8 +146,8 @@ async function handleQueryRecent() {
|
||||
const res = await cpTaskApi.getRecent({ userId: addUserId.value, type: addType.value })
|
||||
recentTasks.value = res.data || []
|
||||
addStep.value = 2
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '查询失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
recentLoading.value = false
|
||||
}
|
||||
@@ -159,6 +166,31 @@ function openAddDialog() {
|
||||
addDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 支持从用户管理页面跳转过来时自动打开弹窗并预填用户
|
||||
function openAddDialogWithUser(userId: number, username: string) {
|
||||
addStep.value = 1
|
||||
addUserId.value = userId
|
||||
addType.value = ''
|
||||
addTitle.value = ''
|
||||
selectedRecent.value = ''
|
||||
recentTasks.value = []
|
||||
skipNextSearch = true
|
||||
userSearchText.value = username
|
||||
userSearchResults.value = []
|
||||
isMatched.value = false
|
||||
addDialogOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const qUserId = route.query.addUserId as string
|
||||
const qUsername = route.query.addUsername as string
|
||||
if (qUserId && qUsername) {
|
||||
openAddDialogWithUser(Number(qUserId), qUsername)
|
||||
// 清除 query 参数,避免刷新时重复弹窗
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
})
|
||||
|
||||
function prevStep() {
|
||||
addStep.value = 1
|
||||
selectedRecent.value = ''
|
||||
@@ -181,8 +213,8 @@ async function handleSaveTask() {
|
||||
toast.success('添加任务成功')
|
||||
addDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '添加任务失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
@@ -194,8 +226,8 @@ async function handleExecuteNow(id: number) {
|
||||
await cpTaskApi.executeNow(id)
|
||||
toast.success('任务已开始执行')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '执行失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,8 +238,8 @@ function handlePause(id: number, currentStatus: string) {
|
||||
await cpTaskApi.pause(id)
|
||||
toast.success(`${action}成功`)
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || `${action}失败`)
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -218,8 +250,8 @@ function handleDelete(id: number) {
|
||||
await cpTaskApi.delete(id)
|
||||
toast.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+128
-200
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, watch } from 'vue'
|
||||
import { ref, h, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { PlusCircle, Trash2, KeyRound, Smartphone, QrCode } from 'lucide-vue-next'
|
||||
import QRCode from 'qrcode'
|
||||
import { PlusCircle, Trash2, KeyRound, Smartphone, QrCode, Loader2, ListPlus } from 'lucide-vue-next'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import type { SearchField } from '@/components/common/DataTable.vue'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
@@ -30,6 +32,16 @@ import type { PageParams, PageResponse } from '@/types/api'
|
||||
import { extractPageData } from '@/types/api'
|
||||
|
||||
const tableRef = ref<{ refresh: () => void } | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// 生成 UUID v4
|
||||
function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 确认弹窗状态 ----
|
||||
const confirmOpen = ref(false)
|
||||
@@ -70,7 +82,6 @@ function resetSchoolSearch() {
|
||||
|
||||
watch(schoolSearchText, (val) => {
|
||||
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
|
||||
// 如果用户清空了搜索框,也清空选中
|
||||
if (!val) {
|
||||
selectedSchool.value = null
|
||||
schoolResults.value = []
|
||||
@@ -82,7 +93,6 @@ watch(schoolSearchText, (val) => {
|
||||
schoolDropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
// 如果已选中且文本没变,不再搜索
|
||||
if (selectedSchool.value && val === selectedSchool.value.name) return
|
||||
schoolSearchTimer = setTimeout(async () => {
|
||||
schoolSearching.value = true
|
||||
@@ -140,7 +150,6 @@ const [addUsername, addUsernameAttrs] = defineAddField('username')
|
||||
const [addPassword, addPasswordAttrs] = defineAddField('password')
|
||||
const [addEmail, addEmailAttrs] = defineAddField('email')
|
||||
|
||||
// 多因子确认所需的暂存数据
|
||||
const confirmAddKey = ref('')
|
||||
const confirmAddUsername = ref('')
|
||||
const confirmAddPassword = ref('')
|
||||
@@ -184,7 +193,7 @@ const onAddByPasswordSubmit = handleAddSubmit(async (values) => {
|
||||
tableRef.value?.refresh()
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '添加账号失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
@@ -200,7 +209,6 @@ const mobileCodeSent = ref(false)
|
||||
const mobileCountdown = ref(0)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 滑块验证码状态
|
||||
const captchaOpen = ref(false)
|
||||
const captchaStep = ref<'getCode' | 'login'>('getCode')
|
||||
|
||||
@@ -223,7 +231,6 @@ function handleGetCodeClick() {
|
||||
}
|
||||
if (!validateSchool()) return
|
||||
captchaStep.value = 'getCode'
|
||||
// 先关闭添加弹窗,避免两层遮罩叠加黑屏
|
||||
addDialogOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 150)
|
||||
}
|
||||
@@ -231,7 +238,6 @@ function handleGetCodeClick() {
|
||||
async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: number; y: number }> }) {
|
||||
captchaOpen.value = false
|
||||
if (captchaStep.value === 'getCode') {
|
||||
// 获取验证码
|
||||
mobileCodeLoading.value = true
|
||||
try {
|
||||
await cpUserApi.getCode({
|
||||
@@ -243,14 +249,12 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
mobileCodeSent.value = true
|
||||
startCountdown()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '获取验证码失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
mobileCodeLoading.value = false
|
||||
// 重新打开添加弹窗
|
||||
addDialogOpen.value = true
|
||||
}
|
||||
} else {
|
||||
// 登录/添加
|
||||
mobileAddLoading.value = true
|
||||
try {
|
||||
await cpUserApi.addByCode({
|
||||
@@ -262,11 +266,8 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
email: mobileEmail.value || undefined,
|
||||
})
|
||||
toast.success('添加账号成功')
|
||||
// 成功后不再打开添加弹窗
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '添加失败')
|
||||
// 失败后重新打开添加弹窗
|
||||
addDialogOpen.value = true
|
||||
} finally {
|
||||
mobileAddLoading.value = false
|
||||
@@ -274,7 +275,6 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码弹窗关闭时(用户手动关闭),恢复添加弹窗
|
||||
function onCaptchaClose(open: boolean) {
|
||||
captchaOpen.value = open
|
||||
if (!open && !mobileCodeLoading.value && !mobileAddLoading.value) {
|
||||
@@ -289,7 +289,6 @@ function onAddByMobileSubmit() {
|
||||
}
|
||||
if (!validateSchool()) return
|
||||
captchaStep.value = 'login'
|
||||
// 先关闭添加弹窗
|
||||
addDialogOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 150)
|
||||
}
|
||||
@@ -319,7 +318,7 @@ async function onConfirmAddSubmit() {
|
||||
dynamicCode.value = ''
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '验证失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
confirmAddLoading.value = false
|
||||
}
|
||||
@@ -361,7 +360,7 @@ const onChangePwdSubmit = handleChangePwdSubmit(async (values) => {
|
||||
toast.success('密码修改成功')
|
||||
changePwdOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '修改密码失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
changePwdLoading.value = false
|
||||
}
|
||||
@@ -375,60 +374,11 @@ function handleDelete(id: number) {
|
||||
toast.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 设备授权弹窗 ----
|
||||
const authDeviceOpen = ref(false)
|
||||
const authDeviceStep = ref(1)
|
||||
const authDeviceUserId = ref(0)
|
||||
const authDeviceLoading = ref(false)
|
||||
const authDataKey = ref('')
|
||||
const authConfirmLoading = ref(false)
|
||||
|
||||
function openAuthDeviceDialog(id: number) {
|
||||
authDeviceUserId.value = id
|
||||
authDeviceStep.value = 1
|
||||
authDataKey.value = ''
|
||||
authDeviceOpen.value = true
|
||||
}
|
||||
|
||||
async function handleAuthorizeDevice() {
|
||||
authDeviceLoading.value = true
|
||||
try {
|
||||
await cpUserApi.authorizeDevice(authDeviceUserId.value)
|
||||
toast.success('授权请求已发送,请输入授权码')
|
||||
authDeviceStep.value = 2
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '授权失败')
|
||||
} finally {
|
||||
authDeviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAuthorizeConfirm() {
|
||||
if (!authDataKey.value) {
|
||||
toast.error('请输入授权码')
|
||||
return
|
||||
}
|
||||
authConfirmLoading.value = true
|
||||
try {
|
||||
await cpUserApi.authorizeConfirm({
|
||||
id: authDeviceUserId.value,
|
||||
dataKey: authDataKey.value,
|
||||
})
|
||||
toast.success('设备授权成功')
|
||||
authDeviceOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '授权确认失败')
|
||||
} finally {
|
||||
authConfirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 自定义设备码弹窗 ----
|
||||
const customDeviceOpen = ref(false)
|
||||
const customDeviceLoading = ref(false)
|
||||
@@ -459,12 +409,56 @@ async function onCustomDeviceSubmit() {
|
||||
customDeviceOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '设置失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
customDeviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 二维码获取设备码弹窗 ----
|
||||
const qrDialogOpen = ref(false)
|
||||
const qrLoading = ref(false)
|
||||
const qrUuid = ref('')
|
||||
|
||||
async function openQrDialog() {
|
||||
qrUuid.value = ''
|
||||
qrDialogOpen.value = true
|
||||
qrLoading.value = true
|
||||
try {
|
||||
const res = await cpUserApi.generateQr()
|
||||
qrUuid.value = res.data.uuid
|
||||
} catch (err: any) {
|
||||
qrDialogOpen.value = false
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取二维码内容URL(App扫码后打开前端授权页)
|
||||
function getQrCodeUrl() {
|
||||
return `${window.location.origin}/cpdaily/authorize/${qrUuid.value}`
|
||||
}
|
||||
|
||||
// QR 码 canvas 渲染
|
||||
const qrCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
watch(qrUuid, async (uuid) => {
|
||||
if (uuid) {
|
||||
await nextTick()
|
||||
if (qrCanvasRef.value) {
|
||||
QRCode.toCanvas(qrCanvasRef.value, getQrCodeUrl(), {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
|
||||
// ---- 表格列定义 ----
|
||||
const columns: ColumnDef<CpUser, any>[] = [
|
||||
{
|
||||
@@ -492,13 +486,10 @@ const columns: ColumnDef<CpUser, any>[] = [
|
||||
header: '授权方式',
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.mobile_type
|
||||
const map: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
random: { label: '随机', variant: 'outline' },
|
||||
authorize: { label: '授权', variant: 'default' },
|
||||
manual: { label: '手动', variant: 'secondary' },
|
||||
if (type === 'manual') {
|
||||
return h(Badge, { variant: 'default' }, () => '手动')
|
||||
}
|
||||
const config = map[type] || { label: type || '未知', variant: 'outline' as const }
|
||||
return h(Badge, { variant: config.variant }, () => config.label)
|
||||
return h(Badge, { variant: 'secondary' }, () => '随机')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -521,9 +512,9 @@ const columns: ColumnDef<CpUser, any>[] = [
|
||||
const user = row.original
|
||||
return h('div', { class: 'flex items-center gap-1' }, [
|
||||
h(IconButton, {
|
||||
icon: QrCode,
|
||||
tooltip: '授权',
|
||||
onClick: () => openAuthDeviceDialog(user.id),
|
||||
icon: ListPlus,
|
||||
tooltip: '添加任务',
|
||||
onClick: () => router.push({ path: '/cpdaily/task', query: { addUserId: String(user.id), addUsername: user.username } }),
|
||||
}),
|
||||
h(IconButton, {
|
||||
icon: Smartphone,
|
||||
@@ -559,11 +550,17 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold tracking-tight">用号管理</h2>
|
||||
<Button size="sm" @click="openAddDialog">
|
||||
<PlusCircle class="mr-1 h-4 w-4" />
|
||||
添加账号
|
||||
</Button>
|
||||
<h2 class="text-2xl font-bold tracking-tight">用户管理</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" @click="openQrDialog">
|
||||
<QrCode class="mr-1 h-4 w-4" />
|
||||
生成二维码
|
||||
</Button>
|
||||
<Button size="sm" @click="openAddDialog">
|
||||
<PlusCircle class="mr-1 h-4 w-4" />
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -601,32 +598,14 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
<!-- 账号密码添加 -->
|
||||
<TabsContent value="password">
|
||||
<form class="space-y-4" @submit.prevent="onAddByPasswordSubmit">
|
||||
<!-- 学校搜索(必填,置顶) -->
|
||||
<div class="space-y-2 relative">
|
||||
<Label>学校 <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
v-model="schoolSearchText"
|
||||
placeholder="输入学校名称搜索(至少2个字)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input v-model="schoolSearchText" placeholder="输入学校名称搜索(至少2个字)" autocomplete="off" />
|
||||
<div v-if="schoolSearching" class="text-sm text-muted-foreground">搜索中...</div>
|
||||
<div
|
||||
v-if="schoolDropdownOpen"
|
||||
class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md"
|
||||
style="top: 100%"
|
||||
>
|
||||
<div
|
||||
v-for="s in schoolResults"
|
||||
:key="s.id"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-accent"
|
||||
@click="selectSchool(s)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</div>
|
||||
<div v-if="schoolDropdownOpen" class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md" style="top: 100%">
|
||||
<div v-for="s in schoolResults" :key="s.id" class="cursor-pointer px-3 py-2 text-sm hover:bg-accent" @click="selectSchool(s)">{{ s.name }}</div>
|
||||
</div>
|
||||
<p v-if="selectedSchool" class="text-sm text-primary">
|
||||
已选择:{{ selectedSchool.name }}
|
||||
</p>
|
||||
<p v-if="selectedSchool" class="text-sm text-primary">已选择:{{ selectedSchool.name }}</p>
|
||||
<p v-if="schoolError" class="text-sm text-destructive">{{ schoolError }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -645,9 +624,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="addDialogOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="addLoading">
|
||||
{{ addLoading ? '提交中...' : '确认添加' }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="addLoading">{{ addLoading ? '提交中...' : '确认添加' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
@@ -655,32 +632,14 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
<!-- 手机号添加 -->
|
||||
<TabsContent value="mobile">
|
||||
<form class="space-y-4" @submit.prevent="onAddByMobileSubmit">
|
||||
<!-- 学校搜索(必填,置顶) -->
|
||||
<div class="space-y-2 relative">
|
||||
<Label>学校 <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
v-model="schoolSearchText"
|
||||
placeholder="输入学校名称搜索(至少2个字)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input v-model="schoolSearchText" placeholder="输入学校名称搜索(至少2个字)" autocomplete="off" />
|
||||
<div v-if="schoolSearching" class="text-sm text-muted-foreground">搜索中...</div>
|
||||
<div
|
||||
v-if="schoolDropdownOpen"
|
||||
class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md"
|
||||
style="top: 100%"
|
||||
>
|
||||
<div
|
||||
v-for="s in schoolResults"
|
||||
:key="s.id"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-accent"
|
||||
@click="selectSchool(s)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</div>
|
||||
<div v-if="schoolDropdownOpen" class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md" style="top: 100%">
|
||||
<div v-for="s in schoolResults" :key="s.id" class="cursor-pointer px-3 py-2 text-sm hover:bg-accent" @click="selectSchool(s)">{{ s.name }}</div>
|
||||
</div>
|
||||
<p v-if="selectedSchool" class="text-sm text-primary">
|
||||
已选择:{{ selectedSchool.name }}
|
||||
</p>
|
||||
<p v-if="selectedSchool" class="text-sm text-primary">已选择:{{ selectedSchool.name }}</p>
|
||||
<p v-if="schoolError" class="text-sm text-destructive">{{ schoolError }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -691,13 +650,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
<Label>验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="mobileCode" placeholder="请输入验证码" class="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleGetCodeClick"
|
||||
:disabled="mobileCodeLoading || mobileCountdown > 0"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" @click="handleGetCodeClick" :disabled="mobileCodeLoading || mobileCountdown > 0">
|
||||
{{ mobileCodeLoading ? '发送中...' : mobileCountdown > 0 ? `${mobileCountdown}s` : '获取验证码' }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -708,9 +661,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="addDialogOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="mobileAddLoading">
|
||||
{{ mobileAddLoading ? '提交中...' : '确认添加' }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="mobileAddLoading">{{ mobileAddLoading ? '提交中...' : '确认添加' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
@@ -732,9 +683,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="confirmAddOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="confirmAddLoading">
|
||||
{{ confirmAddLoading ? '验证中...' : '确认' }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="confirmAddLoading">{{ confirmAddLoading ? '验证中...' : '确认' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -755,43 +704,12 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="changePwdOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="changePwdLoading">
|
||||
{{ changePwdLoading ? '提交中...' : '确认修改' }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="changePwdLoading">{{ changePwdLoading ? '提交中...' : '确认修改' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 设备授权弹窗 -->
|
||||
<Dialog v-model:open="authDeviceOpen">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>设备授权</DialogTitle>
|
||||
<DialogDescription>{{ authDeviceStep === 1 ? '点击开始授权设备' : '请输入授权码完成确认' }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div v-if="authDeviceStep === 1" class="text-center">
|
||||
<Button @click="handleAuthorizeDevice" :disabled="authDeviceLoading">
|
||||
{{ authDeviceLoading ? '授权中...' : '开始授权' }}
|
||||
</Button>
|
||||
</div>
|
||||
<form v-else class="space-y-4" @submit.prevent="onAuthorizeConfirm">
|
||||
<div class="space-y-2">
|
||||
<Label>授权码 (dataKey)</Label>
|
||||
<Input v-model="authDataKey" placeholder="请输入授权码" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="authDeviceOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="authConfirmLoading">
|
||||
{{ authConfirmLoading ? '确认中...' : '确认授权' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 自定义设备码弹窗 -->
|
||||
<Dialog v-model:open="customDeviceOpen">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
@@ -803,40 +721,50 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
|
||||
<div class="space-y-2">
|
||||
<Label>设备码类型</Label>
|
||||
<div class="flex rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="customDeviceType === 'manual' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'"
|
||||
@click="customDeviceType = 'manual'"
|
||||
>
|
||||
手动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border-l"
|
||||
:class="customDeviceType === 'random' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'"
|
||||
@click="customDeviceType = 'random'"
|
||||
>
|
||||
随机
|
||||
</button>
|
||||
<button type="button" class="flex-1 px-4 py-2 text-sm font-medium transition-colors" :class="customDeviceType === 'manual' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'" @click="customDeviceType = 'manual'; customDeviceCode = ''">手动</button>
|
||||
<button type="button" class="flex-1 px-4 py-2 text-sm font-medium transition-colors border-l" :class="customDeviceType === 'random' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'" @click="customDeviceType = 'random'; customDeviceCode = generateUUID()">随机</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>设备码</Label>
|
||||
<Input
|
||||
v-model="customDeviceCode"
|
||||
:placeholder="customDeviceType === 'random' ? '留空则随机生成' : '请输入设备码'"
|
||||
:disabled="customDeviceType === 'random'"
|
||||
/>
|
||||
<Input v-model="customDeviceCode" :placeholder="customDeviceType === 'random' ? '已自动生成' : '请输入设备码'" :disabled="customDeviceType === 'random'" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="customDeviceOpen = false">取消</Button>
|
||||
<Button type="submit" :disabled="customDeviceLoading">
|
||||
{{ customDeviceLoading ? '提交中...' : '确认' }}
|
||||
</Button>
|
||||
<Button type="submit" :disabled="customDeviceLoading">{{ customDeviceLoading ? '提交中...' : '确认' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 二维码获取设备码弹窗 -->
|
||||
<Dialog v-model:open="qrDialogOpen">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>获取设备码</DialogTitle>
|
||||
<DialogDescription>使用今日校园 App 扫描二维码获取设备码</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 py-4">
|
||||
<!-- 加载中 -->
|
||||
<template v-if="qrLoading">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">正在生成二维码...</p>
|
||||
</template>
|
||||
|
||||
<!-- 显示二维码 -->
|
||||
<template v-else-if="qrUuid">
|
||||
<div class="rounded-lg border bg-white p-3">
|
||||
<canvas ref="qrCanvasRef" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
请使用<b><em>今日校园 App</em></b>扫描上方二维码<br>
|
||||
将扫码后获取到的设备码复制发送给我<br>
|
||||
二维码有效期 5 分钟
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,8 +76,8 @@ async function fetchSummary() {
|
||||
async function fetchAnnouncement() {
|
||||
announcementLoading.value = true
|
||||
try {
|
||||
const res = await authApi.getPublicConfig('announcement')
|
||||
if (res.code === 200 && res.data) announcement.value = String(res.data)
|
||||
const res = await authApi.getPublicConfig('system.announcement')
|
||||
if (res.code === 200 && res.data) announcement.value = res.data.value || ''
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
@@ -93,8 +93,14 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="space-y-4 sm:space-y-6">
|
||||
<!-- 公告 -->
|
||||
<Card v-if="announcement" class="announcement-card border-primary/20 bg-primary/5">
|
||||
<CardContent class="p-3 sm:p-4">
|
||||
<Card v-if="announcementLoading">
|
||||
<CardContent class="space-y-2">
|
||||
<div class="h-4 w-3/4 rounded bg-muted animate-pulse" />
|
||||
<div class="h-4 w-1/2 rounded bg-muted animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card v-else-if="announcement">
|
||||
<CardContent>
|
||||
<MdPreview :modelValue="announcement" previewTheme="github" class="announcement-preview" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -221,8 +227,13 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.announcement-preview :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.announcement-preview :deep(.md-editor-preview) {
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
.announcement-preview :deep(.md-editor-preview .md-editor-admonition) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,9 +49,12 @@ async function onRequestCaptchaConfirm(result: { captchaKey: string; dots: Array
|
||||
toast.success('转移链接已发送到邮箱,请查收')
|
||||
captchaOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || '请求失败')
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
if (err?.code === 10003) {
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} else {
|
||||
captchaOpen.value = false
|
||||
}
|
||||
} finally {
|
||||
requestSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const token = ref((route.query.token as string) || '')
|
||||
const token = ref((route.params.token as string) || '')
|
||||
const status = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const message = ref('')
|
||||
const msg = ref('')
|
||||
const autoLoginSuccess = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token.value) {
|
||||
status.value = 'error'
|
||||
message.value = '缺少确认令牌参数'
|
||||
msg.value = '缺少确认令牌参数'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await authApi.confirm(token.value)
|
||||
status.value = 'success'
|
||||
message.value = res.message || '邮箱验证成功'
|
||||
msg.value = res.message || '邮箱验证成功'
|
||||
|
||||
// 后端 confirm 接口会通过响应头返回 token(自动登录)
|
||||
// axios 拦截器已自动将 token 存入 localStorage
|
||||
const savedToken = localStorage.getItem('token')
|
||||
if (savedToken) {
|
||||
autoLoginSuccess.value = true
|
||||
toast.success('验证成功,正在跳转...')
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1500)
|
||||
}
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
message.value = err?.response?.data?.message || '邮箱验证失败'
|
||||
msg.value = err?.response?.data?.message || err?.message || '邮箱验证失败'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -39,16 +53,21 @@ onMounted(async () => {
|
||||
<div v-else-if="status === 'success'" class="space-y-4">
|
||||
<CheckCircle class="h-12 w-12 mx-auto text-green-500" />
|
||||
<h1 class="text-xl font-bold">验证成功</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ message }}</p>
|
||||
<router-link to="/login">
|
||||
<Button class="mt-2">前往登录</Button>
|
||||
</router-link>
|
||||
<p class="text-muted-foreground text-sm">{{ msg }}</p>
|
||||
<template v-if="autoLoginSuccess">
|
||||
<p class="text-sm text-muted-foreground">正在自动跳转...</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login">
|
||||
<Button class="mt-2">前往登录</Button>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<XCircle class="h-12 w-12 mx-auto text-destructive" />
|
||||
<h1 class="text-xl font-bold">验证失败</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ message }}</p>
|
||||
<p class="text-muted-foreground text-sm">{{ msg }}</p>
|
||||
<router-link to="/login">
|
||||
<Button variant="outline" class="mt-2">返回登录</Button>
|
||||
</router-link>
|
||||
|
||||
@@ -39,9 +39,12 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
toast.success('重置邮件已发送,请检查邮箱')
|
||||
captchaOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || '发送失败')
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
if (err?.code === 10003) {
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} else {
|
||||
captchaOpen.value = false
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMouseTracker } from '@/composables/useMouseTracker'
|
||||
import GeometryCharacters from '@/components/login/GeometryCharacters.vue'
|
||||
import LoginForm from '@/components/login/LoginForm.vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
const { targetX, targetY, setFocusTarget } = useMouseTracker()
|
||||
const allowRegister = ref(true)
|
||||
|
||||
function onFocusTarget(el: HTMLElement | null) {
|
||||
setFocusTarget(el)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await authApi.getPublicConfig('system.allowRegister')
|
||||
if (res.code === 200 && res.data) {
|
||||
allowRegister.value = res.data.value === 'true' || res.data.value === '1'
|
||||
}
|
||||
} catch {
|
||||
allowRegister.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,9 +48,10 @@ function onFocusTarget(el: HTMLElement | null) {
|
||||
<LoginForm @focus-target="onFocusTarget" />
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<router-link to="/register" class="text-primary hover:underline">
|
||||
<router-link v-if="allowRegister" to="/register" class="text-primary hover:underline">
|
||||
注册账号
|
||||
</router-link>
|
||||
<span v-else></span>
|
||||
<router-link to="/forgot-password" class="text-muted-foreground hover:text-primary hover:underline">
|
||||
忘记密码?
|
||||
</router-link>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -10,6 +11,10 @@ import { message as toast } from '@/composables/useMessage'
|
||||
import { authApi } from '@/api/auth'
|
||||
import CaptchaDialog from '@/components/login/CaptchaDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const allowRegister = ref(true)
|
||||
const checking = ref(true)
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z.object({
|
||||
email: z.string().email('请输入有效邮箱').regex(/^\d+@qq\.com$/i, '仅支持QQ邮箱注册'),
|
||||
@@ -33,6 +38,28 @@ const [inviteCode, inviteCodeAttrs] = defineField('inviteCode')
|
||||
|
||||
const captchaOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const registered = ref(false)
|
||||
const registeredEmail = ref('')
|
||||
const resending = ref(false)
|
||||
const resendCaptchaOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await authApi.getPublicConfig('system.allowRegister')
|
||||
if (res.code === 200 && res.data) {
|
||||
allowRegister.value = res.data.value === 'true' || res.data.value === '1'
|
||||
}
|
||||
} catch {
|
||||
// 获取失败默认允许注册
|
||||
allowRegister.value = true
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
if (!allowRegister.value) {
|
||||
toast.error('系统当前不允许注册,请联系管理员')
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmitClick() {
|
||||
handleSubmit(() => {
|
||||
@@ -53,15 +80,52 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
dots: result.dots,
|
||||
})
|
||||
toast.success('注册成功,请前往邮箱验证')
|
||||
registeredEmail.value = email.value!
|
||||
registered.value = true
|
||||
captchaOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || '注册失败')
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
if (err?.code === 10003) {
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} else {
|
||||
captchaOpen.value = false
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onResendClick() {
|
||||
const targetEmail = email.value || registeredEmail.value
|
||||
if (!targetEmail) {
|
||||
toast.error('请先填写邮箱')
|
||||
return
|
||||
}
|
||||
registeredEmail.value = targetEmail
|
||||
resendCaptchaOpen.value = true
|
||||
}
|
||||
|
||||
async function onResendCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: number; y: number }> }) {
|
||||
resending.value = true
|
||||
try {
|
||||
await authApi.resendVerification({
|
||||
email: registeredEmail.value,
|
||||
captchaKey: result.captchaKey,
|
||||
dots: result.dots,
|
||||
})
|
||||
toast.success('验证邮件已重新发送,请查收')
|
||||
resendCaptchaOpen.value = false
|
||||
} catch (err: any) {
|
||||
if (err?.code === 10003) {
|
||||
resendCaptchaOpen.value = false
|
||||
setTimeout(() => { resendCaptchaOpen.value = true }, 100)
|
||||
} else {
|
||||
resendCaptchaOpen.value = false
|
||||
}
|
||||
} finally {
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +172,15 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
<router-link to="/login" class="text-primary hover:underline ml-1">去登录</router-link>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
<span class="text-muted-foreground">没有收到验证邮件?</span>
|
||||
<button type="button" class="text-primary hover:underline ml-1" :disabled="resending" @click="onResendClick">
|
||||
{{ resending ? '发送中...' : '点击这里重新发送' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CaptchaDialog v-model:open="captchaOpen" @confirm="onCaptchaConfirm" />
|
||||
<CaptchaDialog v-model:open="resendCaptchaOpen" @confirm="onResendCaptchaConfirm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,15 +7,17 @@ import { z } from 'zod'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AlertCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const token = ref((route.query.token as string) || '')
|
||||
const token = ref((route.params.token as string) || '')
|
||||
const userInfo = ref<{ username?: string; email?: string } | null>(null)
|
||||
const loadingInfo = ref(false)
|
||||
const loadingInfo = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z.object({
|
||||
@@ -34,13 +36,16 @@ const [confirmPassword, confirmPasswordAttrs] = defineField('confirmPassword')
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token.value) return
|
||||
loadingInfo.value = true
|
||||
if (!token.value) {
|
||||
errorMessage.value = '缺少重置令牌参数'
|
||||
loadingInfo.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await authApi.resetPasswordInfo(token.value)
|
||||
userInfo.value = res.data
|
||||
} catch {
|
||||
toast.error('无效或过期的重置链接')
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err?.response?.data?.message || err?.message || '无效或过期的重置链接'
|
||||
} finally {
|
||||
loadingInfo.value = false
|
||||
}
|
||||
@@ -56,8 +61,8 @@ const onSubmit = handleSubmit(async (values) => {
|
||||
})
|
||||
toast.success('密码重置成功,请重新登录')
|
||||
setTimeout(() => router.push('/login'), 1500)
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || '重置失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -72,12 +77,24 @@ const onSubmit = handleSubmit(async (values) => {
|
||||
<p class="text-muted-foreground text-sm">设置新的登录密码</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingInfo" class="text-center text-muted-foreground">加载中...</div>
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loadingInfo" class="flex flex-col items-center space-y-4 py-8">
|
||||
<Loader2 class="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">正在加载账号信息...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!token" class="text-center text-destructive">缺少重置令牌参数</div>
|
||||
<!-- 错误状态:token 无效或过期 -->
|
||||
<div v-else-if="errorMessage || !userInfo" class="flex flex-col items-center space-y-4 py-8">
|
||||
<AlertCircle class="h-12 w-12 text-destructive" />
|
||||
<p class="text-sm text-muted-foreground">{{ errorMessage || '无效的重置链接' }}</p>
|
||||
<router-link to="/login">
|
||||
<Button variant="outline">返回登录</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 正常:显示重置表单 -->
|
||||
<template v-else>
|
||||
<div v-if="userInfo" class="text-center text-sm text-muted-foreground">
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
重置用户:<span class="font-medium text-foreground">{{ userInfo.username }}</span>
|
||||
({{ userInfo.email }})
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ async function fetchData() {
|
||||
}
|
||||
aiTypes.value = typesRes.data?.types ?? []
|
||||
} catch {
|
||||
toast.error('获取 AI 配置失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -80,8 +80,8 @@ const onSave = handleSubmit(async (values) => {
|
||||
apiKey: values.apiKey,
|
||||
})
|
||||
toast.success('AI 配置保存成功')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '保存失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
@@ -105,7 +105,7 @@ const onTest = handleSubmit(async (values) => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
testResult.value = { success: false, message: err.response?.data?.message || '测试请求失败' }
|
||||
toast.error(testResult.value.message)
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ async function fetchAnnouncement() {
|
||||
const val = res.data?.value ?? ''
|
||||
announcementContent.value = val
|
||||
originalAnnouncement.value = val
|
||||
} catch (err: any) {
|
||||
if (err.response?.status !== 404) toast.error('获取公告失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async function saveAnnouncement() {
|
||||
toast.success('公告保存成功')
|
||||
originalAnnouncement.value = announcementContent.value
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
announcementSaving.value = false
|
||||
}
|
||||
@@ -82,7 +82,7 @@ async function saveRegisterConfig() {
|
||||
})
|
||||
toast.success('注册开关保存成功')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
registerSaving.value = false
|
||||
}
|
||||
@@ -119,7 +119,7 @@ async function savePriceConfig() {
|
||||
await systemPriceApi.update(priceConfig.value)
|
||||
toast.success('费率配置保存成功')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
priceSaving.value = false
|
||||
}
|
||||
@@ -171,7 +171,7 @@ onMounted(() => {
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>允许用户注册</Label>
|
||||
<Switch v-model:checked="allowRegister" />
|
||||
<Switch v-model="allowRegister" />
|
||||
<span class="text-sm text-muted-foreground">{{ allowRegister ? '已开启' : '已关闭' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -62,7 +62,7 @@ async function fetchMenuTree() {
|
||||
updateChildMenuList()
|
||||
}
|
||||
} catch {
|
||||
toast.error('获取菜单树失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -199,8 +199,8 @@ function handleDelete(id: number, name: string) {
|
||||
toast.success('删除成功')
|
||||
if (selectedMenu.value?.id === id) selectedMenu.value = null
|
||||
fetchMenuTree()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -298,8 +298,8 @@ const onMenuSubmit = handleMenuSubmit(async (values) => {
|
||||
fetchMenuTree()
|
||||
// 刷新按钮列表
|
||||
if (selectedMenu.value) loadButtons(selectedMenu.value.id)
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '操作失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
@@ -313,8 +313,8 @@ function handleDeleteBtn(id: number, name: string) {
|
||||
toast.success('删除成功')
|
||||
if (selectedMenu.value) loadButtons(selectedMenu.value.id)
|
||||
fetchMenuTree()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ async function fetchPrice() {
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error('获取费率配置失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -63,8 +63,8 @@ const onSave = handleSubmit(async (values) => {
|
||||
try {
|
||||
await systemPriceApi.update(values)
|
||||
toast.success('费率配置保存成功')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '保存失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ const onRoleSubmit = handleRoleSubmit(async (values) => {
|
||||
}
|
||||
editDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '操作失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
@@ -118,8 +118,8 @@ function handleDelete(id: number) {
|
||||
await systemRoleApi.delete(id)
|
||||
toast.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -131,6 +131,18 @@ const menuRoleId = ref(0)
|
||||
const menuTree = ref<UserMenu[]>([])
|
||||
const selectedMenuIds = ref<number[]>([])
|
||||
|
||||
/** 递归将 edges.children 转换为 children */
|
||||
function normalizeMenuTree(nodes: any[]): UserMenu[] {
|
||||
if (!nodes?.length) return []
|
||||
return nodes.map(node => {
|
||||
const edgeChildren = node.edges?.children
|
||||
const directChildren = node.children
|
||||
const rawChildren = edgeChildren?.length ? edgeChildren : directChildren
|
||||
const children = rawChildren?.length ? normalizeMenuTree(rawChildren) : undefined
|
||||
return { ...node, children }
|
||||
})
|
||||
}
|
||||
|
||||
async function openMenuAuth(roleId: number) {
|
||||
menuRoleId.value = roleId
|
||||
menuLoading.value = true
|
||||
@@ -140,10 +152,12 @@ async function openMenuAuth(roleId: number) {
|
||||
systemMenuApi.getTree(),
|
||||
systemRoleApi.getMenu(roleId),
|
||||
])
|
||||
menuTree.value = treeRes.data ?? []
|
||||
selectedMenuIds.value = (roleMenuRes.data as number[]) ?? []
|
||||
menuTree.value = normalizeMenuTree(treeRes.data ?? [])
|
||||
// getMenu 返回的是菜单 ID 数组
|
||||
const ids = roleMenuRes.data
|
||||
selectedMenuIds.value = Array.isArray(ids) ? ids : []
|
||||
} catch {
|
||||
toast.error('获取菜单数据失败')
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
menuLoading.value = false
|
||||
}
|
||||
@@ -164,8 +178,8 @@ async function submitMenuAuth() {
|
||||
await systemRoleApi.authorizeMenu(menuRoleId.value, selectedMenuIds.value)
|
||||
toast.success('菜单授权成功')
|
||||
menuDialogOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '菜单授权失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
menuLoading.value = false
|
||||
}
|
||||
@@ -299,7 +313,7 @@ async function fetchRoles(params: PageParams): Promise<PageResponse<any>> {
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
|
||||
const MenuTreeCheckbox = defineComponent({
|
||||
const MenuTreeCheckbox: ReturnType<typeof defineComponent> = defineComponent({
|
||||
name: 'MenuTreeCheckbox',
|
||||
props: {
|
||||
menu: { type: Object as PropType<UserMenu>, required: true },
|
||||
@@ -308,14 +322,14 @@ const MenuTreeCheckbox = defineComponent({
|
||||
},
|
||||
emits: ['toggle'],
|
||||
setup(props, { emit }) {
|
||||
return () => {
|
||||
return (): any => {
|
||||
const items = []
|
||||
items.push(
|
||||
h('div', { class: 'flex items-center gap-2', style: { paddingLeft: `${props.depth * 20}px` } }, [
|
||||
h(Checkbox, {
|
||||
id: `menu-${props.menu.id}`,
|
||||
checked: props.selectedIds.includes(props.menu.id),
|
||||
'onUpdate:checked': () => emit('toggle', props.menu.id),
|
||||
modelValue: props.selectedIds.includes(props.menu.id),
|
||||
'onUpdate:modelValue': () => emit('toggle', props.menu.id),
|
||||
}),
|
||||
h(Label, { for: `menu-${props.menu.id}`, class: 'cursor-pointer text-sm' }, () => props.menu.name || props.menu.label),
|
||||
]),
|
||||
|
||||
@@ -81,8 +81,8 @@ const onResetPwdSubmit = handleResetPwdSubmit(async (values) => {
|
||||
await systemUserApi.resetPassword(resetPwdUserId.value, values.password)
|
||||
toast.success('密码重置成功')
|
||||
resetPwdOpen.value = false
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '密码重置失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
resetPwdLoading.value = false
|
||||
}
|
||||
@@ -105,8 +105,8 @@ async function openRoleDialog(userId: number, currentRoles?: any[]) {
|
||||
roleList.value = Array.isArray(data) ? data : (data as any)?.pageData ?? []
|
||||
// 预选用户当前角色
|
||||
selectedRoleIds.value = currentRoles?.map((r: any) => r.id) ?? []
|
||||
} catch (err: any) {
|
||||
toast.error('获取角色列表失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
roleLoading.value = false
|
||||
}
|
||||
@@ -128,8 +128,8 @@ async function submitRoleAuth() {
|
||||
toast.success('角色授权成功')
|
||||
roleDialogOpen.value = false
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '角色授权失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
roleLoading.value = false
|
||||
}
|
||||
@@ -144,8 +144,8 @@ async function handleBlock(id: number, currentBlock: boolean) {
|
||||
await systemUserApi.blockUser(id, willBlock)
|
||||
toast.success(`${action}成功`)
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || `${action}失败`)
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -156,8 +156,8 @@ async function handleDelete(id: number) {
|
||||
await systemUserApi.deleteUser(id)
|
||||
toast.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '删除失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -313,8 +313,8 @@ async function fetchUsers(params: PageParams): Promise<PageResponse<any>> {
|
||||
>
|
||||
<Checkbox
|
||||
:id="`role-${role.id}`"
|
||||
:checked="selectedRoleIds.includes(role.id)"
|
||||
@update:checked="toggleRole(role.id)"
|
||||
:model-value="selectedRoleIds.includes(role.id)"
|
||||
@update:model-value="toggleRole(role.id)"
|
||||
/>
|
||||
<Label :for="`role-${role.id}`" class="cursor-pointer">
|
||||
{{ role.name }}
|
||||
|
||||
@@ -50,8 +50,8 @@ const onPasswordSubmit = handlePasswordSubmit(async (values) => {
|
||||
await userApi.resetPassword(values)
|
||||
toast.success('密码修改成功')
|
||||
resetPasswordForm()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '密码修改失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
@@ -69,8 +69,8 @@ async function handleRecharge() {
|
||||
toast.success('充值成功')
|
||||
cardCode.value = ''
|
||||
userStore.fetchProfile()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || '充值失败')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user