This commit is contained in:
ChuXun
2025-10-25 19:18:43 +08:00
parent 4ce487588a
commit 02a830145e
3971 changed files with 1549956 additions and 2 deletions

View File

@@ -0,0 +1,33 @@
# ems-monitoring-system
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

View File

@@ -0,0 +1,3752 @@
{
"name": "ems-monitoring-system",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ems-monitoring-system",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"animate.css": "^4.1.1",
"axios": "^1.10.0",
"date-fns": "^4.1.0",
"element-plus": "^2.10.2",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@antfu/utils": {
"version": "0.7.10",
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz",
"integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz",
"integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.4",
"@babel/parser": "^7.27.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.27.4",
"@babel/types": "^7.27.3",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/generator": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
"@babel/types": "^7.27.3",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
"integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/traverse": "^7.27.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
"integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-proposal-decorators": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz",
"integrity": "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-syntax-decorators": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-decorators": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz",
"integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
"integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-import-meta": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
"integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.10.4"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz",
"integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz",
"integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/parser": "^7.27.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.3",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz",
"integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz",
"integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz",
"integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz",
"integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
"integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz",
"integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz",
"integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz",
"integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz",
"integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz",
"integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz",
"integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz",
"integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz",
"integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz",
"integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz",
"integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz",
"integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz",
"integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz",
"integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz",
"integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz",
"integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz",
"integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz",
"integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@tsconfig/node22": {
"version": "22.0.2",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz",
"integrity": "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "22.15.32",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.14.tgz",
"integrity": "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.14"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.14.tgz",
"integrity": "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.14.tgz",
"integrity": "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.14",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz",
"integrity": "sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/babel-plugin-jsx": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.4.0.tgz",
"integrity": "sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/plugin-syntax-jsx": "^7.25.9",
"@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.9",
"@babel/types": "^7.26.9",
"@vue/babel-helper-vue-transform-on": "1.4.0",
"@vue/babel-plugin-resolve-type": "1.4.0",
"@vue/shared": "^3.5.13"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
}
}
},
"node_modules/@vue/babel-plugin-resolve-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.4.0.tgz",
"integrity": "sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/parser": "^7.26.9",
"@vue/compiler-sfc": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.17.tgz",
"integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
"@vue/shared": "3.5.17",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz",
"integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.17",
"@vue/shared": "3.5.17"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz",
"integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
"@vue/compiler-core": "3.5.17",
"@vue/compiler-dom": "3.5.17",
"@vue/compiler-ssr": "3.5.17",
"@vue/shared": "3.5.17",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz",
"integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.17",
"@vue/shared": "3.5.17"
}
},
"node_modules/@vue/compiler-vue2": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
"dev": true,
"license": "MIT",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/@vue/devtools-api": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7"
}
},
"node_modules/@vue/devtools-core": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.7.7.tgz",
"integrity": "sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7",
"@vue/devtools-shared": "^7.7.7",
"mitt": "^3.0.1",
"nanoid": "^5.1.0",
"pathe": "^2.0.3",
"vite-hot-client": "^2.0.4"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@vue/devtools-core/node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.7",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/language-core": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.10.tgz",
"integrity": "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "~2.4.11",
"@vue/compiler-dom": "^3.5.0",
"@vue/compiler-vue2": "^2.7.16",
"@vue/shared": "^3.5.0",
"alien-signals": "^1.0.3",
"minimatch": "^9.0.3",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz",
"integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.17"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.17.tgz",
"integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.17",
"@vue/shared": "3.5.17"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz",
"integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.17",
"@vue/runtime-core": "3.5.17",
"@vue/shared": "3.5.17",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.17.tgz",
"integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.17",
"@vue/shared": "3.5.17"
},
"peerDependencies": {
"vue": "3.5.17"
}
},
"node_modules/@vue/shared": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.17.tgz",
"integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==",
"license": "MIT"
},
"node_modules/@vue/tsconfig": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz",
"integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"typescript": "5.x",
"vue": "^3.4.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@vueuse/core": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz",
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.13.0",
"@vueuse/shared": "9.13.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
"integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
"dev": true,
"license": "MIT"
},
"node_modules/animate.css": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
"license": "MIT"
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.4.0.tgz",
"integrity": "sha512-5IdNxTyhXHv2UlgnPHQ0h+5ypVmkrYHzL8QT+DwFZ//2N/oNV8Ch+BCRmTJ3x6/z9Axo/cXYBc9eprsUVK/Jsg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/browserslist": {
"version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
"integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001723",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
"integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cross-spawn/node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/default-browser": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
"integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz",
"integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.170",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz",
"integrity": "sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==",
"dev": true,
"license": "ISC"
},
"node_modules/element-plus": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.5.tgz",
"integrity": "sha512-O+bIVHQCjUDm4GiIznDXRoS8ar2TpWLwfOGnN/Aam0VXf5kbuc4SxdKKJdovWNxmxeqbcwjsSZPKgtXNcqys4A==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.18",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser-es": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz",
"integrity": "sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-wsl": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz",
"integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"dev": true,
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
"integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/npm-run-all2": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-7.0.2.tgz",
"integrity": "sha512-7tXR+r9hzRNOPNTvXegM+QzCuMjzUIIq66VDunL6j60O4RrExx32XUhlrS7UK4VcdGw5/Wxzb3kfNcFix9JKDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"cross-spawn": "^7.0.6",
"memorystream": "^0.3.1",
"minimatch": "^9.0.0",
"pidtree": "^0.6.0",
"read-package-json-fast": "^4.0.0",
"shell-quote": "^1.7.3",
"which": "^5.0.0"
},
"bin": {
"npm-run-all": "bin/npm-run-all/index.js",
"npm-run-all2": "bin/npm-run-all/index.js",
"run-p": "bin/run-p/index.js",
"run-s": "bin/run-s/index.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0",
"npm": ">= 9"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz",
"integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-inside-container": "^1.0.0",
"is-wsl": "^3.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz",
"integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==",
"dev": true,
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/read-package-json-fast": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz",
"integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==",
"dev": true,
"license": "ISC",
"dependencies": {
"json-parse-even-better-errors": "^4.0.0",
"npm-normalize-package-bin": "^4.0.0"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz",
"integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.44.0",
"@rollup/rollup-android-arm64": "4.44.0",
"@rollup/rollup-darwin-arm64": "4.44.0",
"@rollup/rollup-darwin-x64": "4.44.0",
"@rollup/rollup-freebsd-arm64": "4.44.0",
"@rollup/rollup-freebsd-x64": "4.44.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.44.0",
"@rollup/rollup-linux-arm-musleabihf": "4.44.0",
"@rollup/rollup-linux-arm64-gnu": "4.44.0",
"@rollup/rollup-linux-arm64-musl": "4.44.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.44.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.44.0",
"@rollup/rollup-linux-riscv64-gnu": "4.44.0",
"@rollup/rollup-linux-riscv64-musl": "4.44.0",
"@rollup/rollup-linux-s390x-gnu": "4.44.0",
"@rollup/rollup-linux-x64-gnu": "4.44.0",
"@rollup/rollup-linux-x64-musl": "4.44.0",
"@rollup/rollup-win32-arm64-msvc": "4.44.0",
"@rollup/rollup-win32-ia32-msvc": "4.44.0",
"@rollup/rollup-win32-x64-msvc": "4.44.0",
"fsevents": "~2.3.2"
}
},
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
"integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sirv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
"integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite-hot-client": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.0.4.tgz",
"integrity": "sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0"
}
},
"node_modules/vite-plugin-inspect": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz",
"integrity": "sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/utils": "^0.7.10",
"@rollup/pluginutils": "^5.1.3",
"debug": "^4.3.7",
"error-stack-parser-es": "^0.1.5",
"fs-extra": "^11.2.0",
"open": "^10.1.0",
"perfect-debounce": "^1.0.0",
"picocolors": "^1.1.1",
"sirv": "^3.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/vite-plugin-vue-devtools": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.7.tgz",
"integrity": "sha512-d0fIh3wRcgSlr4Vz7bAk4va1MkdqhQgj9ANE/rBhsAjOnRfTLs2ocjFMvSUOsv6SRRXU9G+VM7yMgqDb6yI4iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-core": "^7.7.7",
"@vue/devtools-kit": "^7.7.7",
"@vue/devtools-shared": "^7.7.7",
"execa": "^9.5.2",
"sirv": "^3.0.1",
"vite-plugin-inspect": "0.8.9",
"vite-plugin-vue-inspector": "^5.3.1"
},
"engines": {
"node": ">=v14.21.3"
},
"peerDependencies": {
"vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0"
}
},
"node_modules/vite-plugin-vue-inspector": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz",
"integrity": "sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.23.0",
"@babel/plugin-proposal-decorators": "^7.23.0",
"@babel/plugin-syntax-import-attributes": "^7.22.5",
"@babel/plugin-syntax-import-meta": "^7.10.4",
"@babel/plugin-transform-typescript": "^7.22.15",
"@vue/babel-plugin-jsx": "^1.1.5",
"@vue/compiler-dom": "^3.3.4",
"kolorist": "^1.8.0",
"magic-string": "^0.30.4"
},
"peerDependencies": {
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.17",
"@vue/compiler-sfc": "3.5.17",
"@vue/runtime-dom": "3.5.17",
"@vue/server-renderer": "3.5.17",
"@vue/shared": "3.5.17"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.10.tgz",
"integrity": "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "~2.4.11",
"@vue/language-core": "2.2.10"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yoctocolors": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz",
"integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -0,0 +1,36 @@
{
"name": "ems-monitoring-system",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"animate.css": "^4.1.1",
"axios": "^1.10.0",
"date-fns": "^4.1.0",
"element-plus": "^2.10.2",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,29 @@
<template>
<router-view v-slot="{ Component, route }">
<transition
enter-active-class="animate__animated animate__fadeIn"
leave-active-class="animate__animated animate__fadeOut"
mode="out-in"
>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</template>
<script setup lang="ts">
</script>
<style>
html, body, #app {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden; /* Globally disable scrolling */
}
/* You might want to adjust the animation speed */
:root {
--animate-duration: 0.35s;
}
</style>

View File

@@ -0,0 +1,99 @@
// @/api/auth.ts
import apiClient from './index';
import type {
LoginRequest,
LoginResponse,
SignUpRequest,
ResetPasswordWithCodeRequest
} from './types';
/**
* 用户登录(使用 Fetch API
* @param credentials - 登录凭据 (邮箱和密码)
* @returns Promise<LoginResponse>
*/
export const loginWithFetch = async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
let errorMessage = `HTTP error ${response.status}: ${response.statusText}`;
const responseText = await response.text();
try {
const errorBody = JSON.parse(responseText);
if (errorBody && errorBody.message) {
errorMessage = errorBody.message;
} else if (responseText) {
errorMessage = responseText;
}
} catch (e) {
if (responseText) {
errorMessage = responseText;
}
}
const error: any = new Error(errorMessage);
error.status = response.status;
throw error;
}
// If response is OK, we need to parse it as JSON.
// The stream hasn't been read if response.ok is true.
const data = await response.json();
return data;
};
/**
* 用户登录
* @param credentials - 登录凭据 (邮箱和密码)
* @returns Promise<AxiosResponse<LoginResponse>>
*/
export const login = (credentials: LoginRequest) => {
return apiClient.post<LoginResponse>('/auth/login', credentials);
};
/**
* 用户注册
* @param data - 注册所需信息
*/
export const signup = (data: SignUpRequest) => {
return apiClient.post('/auth/signup', data);
};
/**
* 发送【注册】验证码
* @param email - 目标邮箱
*/
export const sendVerificationCode = (email: string) => {
return apiClient.post(`/auth/send-verification-code?email=${email}`);
};
/**
* 发送【密码重置】验证码
* @param email - 目标邮箱
*/
export const sendPasswordResetCode = (email: string) => {
return apiClient.post(`/auth/send-password-reset-code?email=${email}`);
};
/**
* 使用验证码重置密码
* @param data - 包含邮箱、验证码和新密码的对象
*/
export const resetPasswordWithCode = (data: ResetPasswordWithCodeRequest) => {
return apiClient.post('/auth/reset-password-with-code', data);
};
/**
* 用户登出
* 通知后端记录登出操作
*/
export const logout = () => {
return apiClient.post('/auth/logout');
};
// ... 其他认证相关的API调用

View File

@@ -0,0 +1,147 @@
import apiClient from './index';
import type {
DashboardStatsDTO,
AqiDistributionDTO,
TaskStatsDTO,
TrendDataPointDTO,
GridCoverageDTO,
HeatmapPointDTO,
PollutionStatsDTO,
AqiHeatmapPointDTO,
PollutantThresholdDTO,
Grid
} from './types';
/**
* Fetches the main dashboard statistics.
* @returns A promise that resolves to the dashboard stats.
*/
export const getDashboardStats = (): Promise<DashboardStatsDTO> => {
return apiClient.get('/dashboard/stats');
};
/**
* Fetches the AQI level distribution.
* @returns A promise that resolves to an array of AQI distribution data.
*/
export const getAqiDistribution = (): Promise<AqiDistributionDTO[]> => {
return apiClient.get('/dashboard/reports/aqi-distribution');
};
/**
* Fetches the monthly trend of exceedance events.
* @returns A promise that resolves to an array of trend data points.
*/
export const getMonthlyExceedanceTrend = (): Promise<TrendDataPointDTO[]> => {
return apiClient.get('/dashboard/reports/monthly-exceedance-trend')
.then(data => {
console.log('原始趋势数据:', data);
// 确保数据格式正确
if (Array.isArray(data)) {
return data.map(item => ({
yearMonth: item.yearMonth || '',
count: item.count || 0
}));
}
return [];
});
};
/**
* Fetches the grid coverage statistics by city.
* @returns A promise that resolves to an array of grid coverage data.
*/
export const getGridCoverageByCity = (): Promise<GridCoverageDTO[]> => {
return apiClient.get('/dashboard/reports/grid-coverage');
};
/**
* Fetches the heatmap data for pollution incidents.
* @returns A promise that resolves to an array of heatmap points.
*/
export const getHeatmapData = (): Promise<HeatmapPointDTO[]> => {
return apiClient.get('/dashboard/map/heatmap');
};
/**
* Fetches the pollution statistics.
* @returns A promise that resolves to an array of pollution stats.
*/
export const getPollutionStats = (): Promise<PollutionStatsDTO[]> => {
return apiClient.get('/dashboard/reports/pollution-stats');
};
/**
* Fetches the task completion statistics.
* @returns A promise that resolves to the task stats.
*/
export const getTaskCompletionStats = (): Promise<TaskStatsDTO> => {
return apiClient.get('/dashboard/reports/task-completion-stats');
};
/**
* Fetches the AQI heatmap data.
* @returns A promise that resolves to an array of AQI heatmap points.
*/
export const getAqiHeatmapData = (): Promise<AqiHeatmapPointDTO[]> => {
return apiClient.get('/dashboard/map/aqi-heatmap');
};
/**
* Fetches all pollutant thresholds.
* @returns A promise that resolves to an array of pollutant thresholds.
*/
export const getPollutantThresholds = (): Promise<PollutantThresholdDTO[]> => {
return apiClient.get('/dashboard/thresholds');
};
/**
* Fetches a specific pollutant threshold.
* @param pollutantName The name of the pollutant.
* @returns A promise that resolves to the pollutant threshold.
*/
export const getPollutantThreshold = (pollutantName: string): Promise<PollutantThresholdDTO> => {
return apiClient.get(`/dashboard/thresholds/${pollutantName}`);
};
/**
* Saves a pollutant threshold.
* @param threshold The pollutant threshold to save.
* @returns A promise that resolves to the saved pollutant threshold.
*/
export const savePollutantThreshold = (threshold: PollutantThresholdDTO): Promise<PollutantThresholdDTO> => {
return apiClient.post('/dashboard/thresholds', threshold);
};
/**
* 获取每种污染物的月度趋势数据
* @returns 每种污染物的月度趋势数据
*/
export const getPollutantMonthlyTrends = (value: string): Promise<Record<string, TrendDataPointDTO[]>> => {
return apiClient.get('/dashboard/reports/pollutant-monthly-trends')
.then(data => {
console.log('原始污染物趋势数据:', data);
// 确保数据格式正确
if (data && typeof data === 'object') {
const result: Record<string, TrendDataPointDTO[]> = {};
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
result[key] = value.map(item => ({
yearMonth: item.yearMonth || '',
count: item.count || 0
}));
}
});
return result;
}
return {};
});
};
/**
* Fetches all grid data from the server.
* @returns A promise that resolves to an array of Grid objects.
*/
export const getGrids = (): Promise<Grid[]> => {
return apiClient.get('/grids');
};

View File

@@ -0,0 +1,292 @@
import apiClient from './index';
import type { Page, AssigneeInfoDTO, UserInfoDTO, TaskInfoDTO, AttachmentDTO } from './types';
import { PollutionType } from './types';
import type { AxiosResponse } from 'axios';
// 导出必要的类型
export { PollutionType };
// API URL
const API_URL = '/feedback';
// --- Start: Copied from TaskDetailView.vue for now ---
// This is technical debt and should be refactored into a central types file.
interface FeedbackDTO {
feedbackId: number;
eventId: string;
title: string;
}
interface AssigneeDTO {
id: number;
name: string;
phone: string;
}
interface TaskHistoryDTO {
id: number;
oldStatus: string | null;
newStatus: string;
comments: string;
changedAt: string;
changedBy: {
id: number;
name:string;
};
}
interface SubmissionInfoDTO {
comments: string;
attachments: AttachmentDTO[];
}
export interface TaskDetail {
id: number;
feedback: FeedbackDTO;
title: string;
description: string;
status: string;
assignee: AssigneeDTO;
assignedAt: string | null;
completedAt: string | null;
history: TaskHistoryDTO[];
submissionInfo: SubmissionInfoDTO | null;
}
// --- End: Copied from TaskDetailView.vue ---
/**
* 反馈状态枚举
*/
export enum FeedbackStatus {
AI_REVIEWING = 'AI_REVIEWING', // AI审核中
PENDING_REVIEW = 'PENDING_REVIEW', // 待人工审核
REJECTED = 'REJECTED', // 已拒绝
CONFIRMED = 'CONFIRMED', // 已确认
ASSIGNED = 'ASSIGNED', // 已分配
IN_PROGRESS = 'IN_PROGRESS', // 处理中
RESOLVED = 'RESOLVED', // 已解决
CLOSED = 'CLOSED', // 已关闭
PENDING_ASSIGNMENT = 'PENDING_ASSIGNMENT', // 待分配
PROCESSED = 'PROCESSED' // 已处理
}
/**
* 严重程度枚举
*/
export enum SeverityLevel {
LOW = 'LOW', // 低
MEDIUM = 'MEDIUM', // 中
HIGH = 'HIGH' // 高
}
/**
* 反馈列表项接口 (现在匹配后端的 FeedbackResponseDTO)
*/
export interface FeedbackResponse {
id: number;
eventId: string;
title: string;
description: string;
pollutionType: PollutionType;
severityLevel: SeverityLevel;
status: FeedbackStatus;
textAddress: string;
gridX: number;
gridY: number;
latitude: number;
longitude: number;
createdAt: string; // ISO date string
updatedAt: string; // ISO date string
submitterId: number;
user: UserInfoDTO;
task: TaskDetail | null; // Task can be null if not assigned
attachments: AttachmentDTO[];
}
/**
* 反馈详情接口 (为了简化,我们可以让它继承自 FeedbackResponse)
*/
export interface FeedbackDetail extends FeedbackResponse {
aiAnalysis?: string;
reviewNotes?: string;
assignmentId?: number;
assignmentMethod?: string;
assignmentTime?: string;
assignedWorkerId?: number;
resolutionNotes?: string;
resolutionTime?: string;
// `images` is now `attachments`, which is already in FeedbackResponse
}
/**
* 反馈筛选参数接口
*/
export interface FeedbackFilters {
status?: FeedbackStatus;
pollutionType?: PollutionType;
severityLevel?: SeverityLevel;
cityName?: string;
districtName?: string;
startDate?: string;
endDate?: string;
keyword?: string;
page?: number;
size?: number;
}
/**
* 反馈处理请求接口
*/
export interface ProcessFeedbackRequest {
status: FeedbackStatus;
notes?: string;
}
/**
* 反馈分配请求接口
*/
export interface AssignFeedbackRequest {
gridWorkerId: number;
notes?: string;
}
/**
* 反馈拒绝请求接口
*/
export interface RejectFeedbackRequest {
notes: string;
}
export interface LocationInfo {
latitude: number;
longitude: number;
textAddress: string;
gridX: number;
gridY: number;
}
export interface FeedbackSubmissionRequest {
title: string;
description: string;
pollutionType: PollutionType;
severityLevel: SeverityLevel;
location: LocationInfo;
}
/**
* 获取反馈列表
* @param params 筛选参数
* @returns 分页反馈列表
*/
export function getFeedbackList(params?: FeedbackFilters): Promise<Page<FeedbackResponse>> {
return apiClient.get(API_URL, { params });
}
/**
* 获取反馈详情
* @param id 反馈ID
* @returns 反馈详情
*/
export function getFeedbackDetail(id: number): Promise<AxiosResponse<FeedbackDetail>> {
return apiClient.get(`/feedback/${id}`);
}
/**
* 处理反馈
* @param id 反馈ID
* @param data 处理数据
* @returns 处理结果
*/
export function processFeedback(id: number, request: { status: string; notes?: string }): Promise<AxiosResponse<FeedbackDetail>> {
return apiClient.post(`/feedback/${id}/process`, request);
}
/**
* 分配反馈给网格员
* @param id 反馈ID
* @param data 分配数据
* @returns 分配结果
*/
export function assignFeedback(id: number, data: AssignFeedbackRequest): Promise<any> {
return apiClient.post(`${API_URL}/${id}/assign`, data);
}
/**
* 标记反馈为已解决
* @param id 反馈ID
* @param notes 解决说明
* @returns 操作结果
*/
export function resolveFeedback(id: number, notes?: string): Promise<any> {
return apiClient.post(`${API_URL}/${id}/resolve`, { notes });
}
/**
* 关闭反馈
* @param id 反馈ID
* @param notes 关闭说明
* @returns 操作结果
*/
export function closeFeedback(id: number, notes?: string): Promise<any> {
return apiClient.post(`${API_URL}/${id}/close`, { notes });
}
/**
* 拒绝反馈
* @param id 反馈ID
* @param data 拒绝数据
* @returns 操作结果
*/
export function rejectFeedback(id: number, data: RejectFeedbackRequest): Promise<any> {
return apiClient.post(`/supervisor/reviews/${id}/reject`, data);
}
/**
* 确认反馈
* @param id 反馈ID
* @returns 响应体
*/
export function confirmFeedback(id: number): Promise<any> {
return apiClient.post(`${API_URL}/${id}/confirm`);
}
/**
* 提交新的反馈
* @param formData 包含反馈数据和文件的表单
* @returns 提交结果
*/
export function submitFeedback(formData: FormData): Promise<any> {
return apiClient.post(`${API_URL}/submit`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* 获取反馈统计数据
* @returns 反馈统计数据
*/
export const getFeedbackStats = async () => {
try {
const response = await apiClient.get(`${API_URL}/stats`);
return response || {}; // 如果响应为空,返回一个空对象
} catch (error) {
console.error('获取反馈统计数据API错误:', error);
// 即使API失败也返回一个默认的统计对象结构防止UI崩溃
return {
total: 0,
pending: 0,
confirmed: 0,
assigned: 0,
inProgress: 0,
resolved: 0,
closed: 0,
rejected: 0,
};
}
};
/**
* 获取待处理的反馈列表
* @returns 待处理的反馈列表
*/
export function getPendingFeedback(): Promise<AxiosResponse<FeedbackResponse[]>> {
return apiClient.get('/feedback/pending');
}

View File

@@ -0,0 +1,42 @@
import apiClient from './index';
import type { Grid } from './types';
/**
* DTO for updating a grid's core properties.
* This should align with GridUpdateRequest in the backend.
*/
export interface GridUpdateRequest {
isObstacle?: boolean;
description?: string;
}
/**
* 更新网格信息
* @param gridId - 要更新的网格ID
* @param data - 包含更新数据的对象
* @returns 更新后的网格对象
*/
export const updateGrid = (gridId: number, data: GridUpdateRequest): Promise<Grid> => {
return apiClient.patch(`/grids/${gridId}`, data);
};
/**
* 通过坐标将网格员分配到网格
* @param gridX - 网格X坐标
* @param gridY - 网格Y坐标
* @param userId - 要分配的用户ID
* @returns 成功则返回void
*/
export const assignWorkerByCoordinates = (gridX: number, gridY: number, userId: number): Promise<void> => {
return apiClient.post(`/grids/coordinates/${gridX}/${gridY}/assign`, { userId });
};
/**
* 通过坐标从网格中移除网格员
* @param gridX - 网格X坐标
* @param gridY - 网格Y坐标
* @returns 成功则返回void
*/
export const unassignWorkerByCoordinates = (gridX: number, gridY: number): Promise<void> => {
return apiClient.post(`/grids/coordinates/${gridX}/${gridY}/unassign`);
};

View File

@@ -0,0 +1,50 @@
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // 从环境变量或默认值设置基础URL
headers: {
'Content-Type': 'application/json',
},
});
// 添加请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 这个拦截器会在每个请求发送前执行
const token = localStorage.getItem('token');
if (token) {
// 为请求头添加Authorization字段
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error);
}
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => {
// 对响应数据做点什么
return response.data;
},
(error) => {
// 对响应错误做点什么
if (error.response && error.response.status === 401) {
// 如果是401错误说明token无效或过期
// 我们需要调用auth store的logout方法
// 为了避免循环依赖我们在函数内部获取store实例
const authStore = useAuthStore();
authStore.logout();
// 在这里重定向到登录页
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,10 @@
import apiClient from '.';
import type { OperationLog } from './types';
export function getOperationLogs(params: any): Promise<OperationLog[]> {
return apiClient.get('/logs', { params });
}
export function getMyOperationLogs(): Promise<OperationLog[]> {
return apiClient.get('/logs/my-logs');
}

View File

@@ -0,0 +1,30 @@
import apiClient from './index';
/**
* Represents a point with x and y coordinates.
* Matches the backend Point DTO.
*/
export interface Point {
x: number;
y: number;
}
/**
* Represents the request payload for finding a path.
* Matches the backend PathfindingRequest DTO.
*/
export interface PathfindingRequest {
startX: number;
startY: number;
endX: number;
endY: number;
}
/**
* Calls the backend API to find a path between two points.
* @param request The pathfinding request containing start and end coordinates.
* @returns A promise that resolves to a list of points representing the path.
*/
export const findPath = (request: PathfindingRequest): Promise<Point[]> => {
return apiClient.post('/pathfinding/find', request);
};

View File

@@ -0,0 +1,40 @@
import apiClient from './index';
import type { Page, Pageable, UserAccount, UserUpdateRequest } from './types';
/**
* 获取用户列表(可分页、可筛选)
* @param params - 包含分页和筛选条件的参数
* @returns 用户数据分页对象
*/
export const getUsers = (params: { role?: string; name?: string; } & Pageable): Promise<Page<UserAccount>> => {
return apiClient.get('/personnel/users', { params });
};
/**
* 获取所有网格员信息
* @returns 所有网格员的列表
*/
export const getAllGridWorkers = async (): Promise<UserAccount[]> => {
// We fetch with a large size to get all workers, assuming the number is manageable.
// A more robust solution might involve paginating through all results if the number of workers is very large.
const response = await getUsers({ role: 'GRID_WORKER', page: 0, size: 1000 });
return response.content;
};
/**
* 根据ID更新用户信息
* @param userId - 用户ID
* @param data - 需要更新的用户信息
* @returns 更新后的用户信息
*/
export const updateUser = (userId: number, data: UserUpdateRequest): Promise<UserAccount> => {
return apiClient.patch(`/personnel/users/${userId}`, data);
};
/**
* 根据ID删除用户
* @param userId - 用户ID
*/
export const deleteUser = (userId: number): Promise<void> => {
return apiClient.delete(`/personnel/users/${userId}`);
};

View File

@@ -0,0 +1,15 @@
import apiClient from './index';
/**
* Submits public feedback.
* This endpoint is for unauthenticated users (guests).
* @param formData The form data containing the feedback details and any files.
* @returns A promise that resolves on successful submission.
*/
export const submitPublicFeedback = (formData: FormData): Promise<any> => {
return apiClient.post('/public/feedback', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};

View File

@@ -0,0 +1,37 @@
import type { AxiosResponse, AxiosError } from 'axios';
import apiClient from './index';
import type { UserAccount } from './types';
export const getAvailableGridWorkers = (): Promise<UserAccount[]> => {
// 根据TaskAssignmentController的路径应为/tasks/grid-workers
return apiClient.get<UserAccount[]>('/tasks/grid-workers')
.then((response: AxiosResponse<UserAccount[]>) => response.data)
.catch((error: AxiosError) => {
console.error('获取可用网格员API错误:', error);
// 如果API调用失败返回空数组而不是模拟数据
return [];
});
};
export const assignTask = (feedbackId: number, assigneeId: number): Promise<any> => {
return apiClient.post('/tasks/assign', { feedbackId, assigneeId });
};
/**
* Approves a submitted task.
* @param taskId The ID of the task to approve.
* @returns A promise that resolves with the updated task details.
*/
export const approveTask = (taskId: number): Promise<any> => {
return apiClient.post(`/management/tasks/${taskId}/approve`);
};
/**
* Rejects a submitted task.
* @param taskId The ID of the task to reject.
* @param reason The reason for rejection.
* @returns A promise that resolves with the updated task details.
*/
export const rejectTask = (taskId: number, reason: string): Promise<any> => {
return apiClient.post(`/management/tasks/${taskId}/reject`, { reason });
};

View File

@@ -0,0 +1,307 @@
/**
* 通用用户信息类型, 反映了JWT中包含的声明
*/
export interface User {
id: number;
name: string;
email: string;
role: string;
phone?: string;
status?: string;
region?: string;
skills?: string[];
gridX?: number;
gridY?: number;
}
/**
* 用户登录请求体
*/
export interface LoginRequest {
email: string;
password: string;
}
/**
* 登录成功响应体, 与后端完全匹配
*/
export interface LoginResponse {
accessToken: string;
}
/**
* 用户注册请求体
*/
export interface SignUpRequest {
name: string;
email: string;
phone: string;
password: string;
verificationCode: string;
role: string;
}
/**
* 使用验证码重置密码请求体, 对应后端的 PasswordResetWithCodeDto
*/
export interface ResetPasswordWithCodeRequest {
email: string;
code: string;
newPassword: string;
}
/**
* 分页请求参数
*/
export interface Pageable {
page?: number;
size?: number;
sort?: string;
}
/**
* 分页响应体
*/
export interface Page<T> {
content: T[];
totalPages: number;
totalElements: number;
size: number;
number: number;
}
/**
* 后端 UserAccount 实体类的完整映射
*/
export interface UserAccount {
id: number;
name: string;
email: string;
phone: string;
role: string;
status: string;
gender?: string;
region?: string;
gridX?: number;
gridY?: number;
level?: string;
skills?: string[];
currentLatitude?: number;
currentLongitude?: number;
createdAt: string; // Assuming ISO date string
updatedAt: string; // Assuming ISO date string
}
/**
* 用户更新请求体, 对应后端的 UserUpdateRequest
*/
export interface UserUpdateRequest {
name?: string;
phone?: string;
region?: string;
role?: string;
status?: string;
gender?: 'MALE' | 'FEMALE' | 'OTHER';
gridX?: number;
gridY?: number;
level?: string;
skills?: string[];
currentLatitude?: number;
currentLongitude?: number;
}
// --- Dashboard DTOs ---
/**
* Key statistics for the main dashboard.
* @param totalFeedbacks - Total number of feedbacks.
* @param confirmedFeedbacks - Number of confirmed feedbacks.
* @param totalAqiRecords - Total number of AQI records.
* @param activeGridWorkers - Number of active grid workers.
*/
export type DashboardStatsDTO = {
totalFeedbacks: number;
confirmedFeedbacks: number;
totalAqiRecords: number;
activeGridWorkers: number;
};
/**
* Data for AQI level distribution chart.
* @param level - The AQI descriptive level (e.g., "Good", "Moderate").
* @param count - The number of monitoring points at this level.
*/
export type AqiDistributionDTO = {
level: string;
count: number;
};
/**
* A single data point for a time-series trend chart.
* @param yearMonth - The date for the data point in "YYYY-MM" format.
* @param count - The value for that date (e.g., number of exceedances).
*/
export type TrendDataPointDTO = {
yearMonth: string;
count: number;
};
/**
* Grid coverage statistics for a specific city.
* @param city - The name of the city.
* @param totalGrids - The total number of grids in the city.
* @param coveredGrids - The number of grids with assigned personnel.
* @param coverageRate - The calculated coverage percentage.
*/
export type GridCoverageDTO = {
city: string;
totalGrids: number;
coveredGrids: number;
coverageRate: number;
};
/**
* A single point for a heatmap visualization.
* @param lat - Latitude of the data point.
* @param lng - Longitude of the data point.
* @param value - The intensity value for the heatmap.
*/
export type HeatmapPointDTO = {
lat: number;
lng: number;
value: number;
};
/**
* Detailed statistics for a specific pollutant.
* @param pollutantName - The name of the pollutant (e.g., "PM2.5").
* @param averageValue - The average value over a period.
* @param maxValue - The maximum recorded value.
* @param unit - The measurement unit (e.g., "µg/m³").
*/
export type PollutionStatsDTO = {
pollutantName: string;
averageValue: number;
maxValue: number;
unit: string;
};
/**
* Statistics on task completion status.
* @param totalTasks - Total number of tasks.
* @param completedTasks - Number of completed tasks.
* @param completionRate - Completion rate of tasks.
*/
export type TaskStatsDTO = {
totalTasks: number;
completedTasks: number;
completionRate: number;
};
/**
* A single point for the AQI heatmap, including pollutant info.
* Extends HeatmapPointDTO with additional details.
*/
export type AqiHeatmapPointDTO = HeatmapPointDTO & {
primaryPollutant: string;
aqi: number;
};
/**
* 污染物类型枚举
*/
export enum PollutionType {
PM25 = 'PM25',
O3 = 'O3',
NO2 = 'NO2',
SO2 = 'SO2',
OTHER = 'OTHER'
}
/**
* 污染物阈值设置
* @param pollutionType - 污染物类型
* @param pollutantName - 污染物名称
* @param threshold - 阈值
* @param unit - 单位
* @param description - 描述
*/
export type PollutantThresholdDTO = {
pollutionType: PollutionType;
pollutantName: string;
threshold: number;
unit: string;
description?: string;
};
/**
* Represents a geographical grid cell.
* Matches the backend Grid entity.
*/
export interface Grid {
id: number;
gridX: number;
gridY: number;
cityName: string;
districtName?: string;
description?: string;
isObstacle: boolean;
}
// ... 您可以根据API文档继续添加其他类型定义
// --- Task and Assignment DTOs ---
export interface AssigneeInfoDTO {
id: number;
name: string;
phone: string;
}
export interface UserInfoDTO {
id: number;
name: string;
phone: string;
email: string;
}
export interface TaskInfoDTO {
id: number;
status: string; // Should match TaskStatus enum from backend
assignee: AssigneeInfoDTO;
submissionInfo: SubmissionInfoDTO | null;
}
export interface AttachmentDTO {
id: number;
fileName: string;
fileType: string;
url: string;
uploadedAt: string; // ISO date string
}
export interface SubmissionInfoDTO {
comments: string;
attachments: AttachmentDTO[];
}
/**
* 操作日志类型
*/
export interface OperationLog {
id: number;
userId: number;
userName: string;
operationType: string;
operationTypeDesc: string;
description: string;
targetId: string;
targetType: string;
ipAddress: string;
createdAt: string;
}
/**
* Task Summary DTO for supervisor view.
* This should match `TaskSummaryDTO` from the backend.
*/

View File

@@ -0,0 +1,74 @@
import apiClient from './index';
import type { GridWorker } from './grid';
/**
* 用户信息接口
*/
export interface UserInfo {
id: number;
name: string;
email: string;
phone: string;
role: string;
avatar?: string;
permissions?: string[];
}
/**
* 用户更新请求接口
*/
export interface UserUpdateRequest {
name?: string;
phone?: string;
email?: string;
region?: string;
level?: string;
gridX?: number | null;
gridY?: number | null;
currentLatitude?: number | null;
currentLongitude?: number | null;
skills?: string[];
status?: string;
gender?: string;
}
/**
* 获取当前登录用户信息
* @returns 用户信息
*/
export function getCurrentUser(): Promise<UserInfo> {
return apiClient.get('/auth/profile');
}
/**
* 更新用户资料
* @param userId 用户ID
* @param data 更新数据
* @returns 更新后的用户信息
*/
export function updateUserProfile(userId: number, data: UserUpdateRequest): Promise<GridWorker> {
console.log(`API调用: 更新用户资料, userId=${userId}`, data);
return apiClient.patch(`/personnel/users/${userId}`, data)
.then(response => {
console.log('更新用户资料API调用成功:', response.data);
return response.data;
})
.catch(error => {
console.error('更新用户资料API调用失败:', error);
console.error('错误详情:', error.response?.data || error.message);
throw error;
});
}
/**
* 更新用户角色
* @param userId 用户ID
* @param role 角色名称
* @param gridX 网格X坐标可选
* @param gridY 网格Y坐标可选
* @returns 更新后的用户信息
*/
export function updateUserRole(userId: number, role: string, gridX?: number, gridY?: number): Promise<GridWorker> {
const data = { role, gridX, gridY };
return apiClient.put(`/personnel/users/${userId}/role`, data).then(response => response.data);
}

View File

@@ -0,0 +1,90 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-green-dark: #2E7D32;
--vt-c-green: #4CAF50;
--vt-c-green-light: #81C784;
--vt-c-green-soft: #C8E6C9;
--vt-c-green-mute: #E8F5E9;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-green-dark);
--vt-c-text-light-2: rgba(46, 125, 50, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-green-mute);
--color-background-soft: var(--vt-c-green-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-green-light);
--color-text: var(--vt-c-green-soft);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,607 @@
<template>
<el-dialog
:model-value="visible"
title="反馈详情"
width="70%"
:before-close="handleClose"
class="feedback-detail-dialog"
top="5vh"
>
<template #header>
<div class="dialog-header">
<span class="el-dialog__title">反馈详情</span>
<el-button
type="primary"
:icon="Refresh"
circle
@click="handleRefresh"
:loading="loading"
title="刷新数据"
/>
</div>
</template>
<div v-if="loading" v-loading="loading" class="loading-container"></div>
<div v-if="!loading && currentFeedback" class="detail-container">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentFeedback.id }}</el-descriptions-item>
<el-descriptions-item label="事件编号">{{ currentFeedback.eventId || '无' }}</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ currentFeedback.title }}</el-descriptions-item>
<el-descriptions-item label="污染类型">{{ formatPollutionType(currentFeedback.pollutionType) }}</el-descriptions-item>
<el-descriptions-item label="严重程度">
<el-tag :type="getSeverityTagType(currentFeedback.severityLevel)">
{{ formatSeverityLevel(currentFeedback.severityLevel) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentFeedback.status)">
{{ formatStatus(currentFeedback.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDate(currentFeedback.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentFeedback.updatedAt) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="1" border class="mt-4">
<el-descriptions-item label="详细描述">
<div class="description-content">{{ currentFeedback.description }}</div>
</el-descriptions-item>
<el-descriptions-item label="提交位置" v-if="currentFeedback.latitude && currentFeedback.longitude">
{{ currentFeedback.textAddress || `经纬度: (${currentFeedback.longitude}, ${currentFeedback.latitude})` }}
</el-descriptions-item>
<el-descriptions-item label="报告人信息" v-if="currentFeedback.user">
{{ currentFeedback.user.name }}
<el-tag size="small" style="margin-left: 8px;">{{ formatRole(currentFeedback.user.role) }}</el-tag>
<div class="mt-1">ID: {{ currentFeedback.user.id }}, 电话: {{ currentFeedback.reporterPhone || '未提供' }}</div>
<div class="user-note mt-1" v-if="currentFeedback.user.role === 'GRID_WORKER'">
<el-alert
type="warning"
:closable="false"
show-icon
title="注意:通常反馈应由公众监督员提交,此处显示为网格员可能是测试数据"
/>
</div>
</el-descriptions-item>
</el-descriptions>
<div class="task-details mt-4" v-if="currentFeedback.task">
<h4>任务详情</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ currentFeedback.task.id }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="getStatusTagType(currentFeedback.task.status)">
{{ formatStatus(currentFeedback.task.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处理人" v-if="currentFeedback.task.assignee">
{{ currentFeedback.task.assignee.name }} (ID: {{ currentFeedback.task.assignee.id }})
</el-descriptions-item>
<el-descriptions-item label="处理人电话" v-if="currentFeedback.task.assignee">
{{ currentFeedback.task.assignee.phone || '未提供' }}
</el-descriptions-item>
<el-descriptions-item label="网格坐标" :span="2" v-if="currentFeedback.task.gridX !== null && currentFeedback.task.gridY !== null">
X: {{ currentFeedback.task.gridX }}, Y: {{ currentFeedback.task.gridY }}
</el-descriptions-item>
<el-descriptions-item label="分配时间" v-if="currentFeedback.task.assignment?.assignmentTime">
{{ formatDate(currentFeedback.task.assignment.assignmentTime) }}
</el-descriptions-item>
<el-descriptions-item label="分配说明" v-if="currentFeedback.task.assignment?.remarks">
{{ currentFeedback.task.assignment.remarks }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 任务提交详情 -->
<div class="submission-details mt-4" v-if="currentFeedback.task && currentFeedback.task.submissionInfo">
<h4>任务提交详情</h4>
<el-descriptions :column="1" border>
<el-descriptions-item label="提交说明">
{{ currentFeedback.task.submissionInfo.comments }}
</el-descriptions-item>
<el-descriptions-item label="提交附件" v-if="currentFeedback.task.submissionInfo.attachments && currentFeedback.task.submissionInfo.attachments.length > 0">
<div v-for="att in currentFeedback.task.submissionInfo.attachments" :key="att.id">
<el-link :href="att.url" type="primary" target="_blank" :icon="Document">{{ att.fileName }}</el-link>
</div>
</el-descriptions-item>
<el-descriptions-item label="无附件" v-else>
未提交任何附件
</el-descriptions-item>
</el-descriptions>
</div>
<div class="image-gallery mt-4" v-if="currentFeedback.attachments && currentFeedback.attachments.length > 0">
<h4>相关图片</h4>
<el-image
v-for="att in currentFeedback.attachments"
:key="att.id"
:src="att.url"
:preview-src-list="currentFeedback.attachments.map(a => a.url)"
:initial-index="currentFeedback.attachments.findIndex(a => a.id === att.id)"
fit="cover"
class="feedback-image"
lazy
/>
</div>
</div>
<div v-if="!loading && !currentFeedback" class="empty-state">
<el-empty description="无法加载反馈详情" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<!-- 任务审批按钮 -->
<template v-if="currentFeedback?.task?.status === 'SUBMITTED' && canManageSubmittedTask">
<el-button type="success" @click="handleApprove" :loading="approving">批准通过</el-button>
<el-button type="danger" @click="handleRejectTask" :loading="rejecting">打回任务</el-button>
</template>
<!-- 根据状态条件性显示操作按钮 -->
<el-button type="primary" @click="handleProcess" v-if="canProcess">处理</el-button>
<el-button type="success" @click="handleAssign" v-if="canAssign">分配任务</el-button>
<el-button type="warning" @click="handleRejectFeedback" v-if="canReject">拒绝反馈</el-button>
</div>
</template>
</el-dialog>
<!-- 分配任务对话框 -->
<el-dialog
v-model="assignDialogVisible"
title="分配任务"
width="30%"
:before-close="() => assignDialogVisible = false"
>
<div v-if="loadingGridWorkers">正在加载网格员...</div>
<el-select
v-else
v-model="selectedGridWorkerId"
placeholder="请选择网格员"
style="width: 100%;"
>
<el-option
v-for="worker in gridWorkers"
:key="worker.id"
:label="`${worker.name} (ID: ${worker.id})${worker.phone ? ' - ' + worker.phone : ''}`"
:value="worker.id"
/>
</el-select>
<template #footer>
<span class="dialog-footer">
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAssign" :disabled="!selectedGridWorkerId">
确认分配
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, h } from 'vue';
import { useFeedbackStore } from '@/store/feedback';
import { storeToRefs } from 'pinia';
import { ElMessage, ElMessageBox, ElSelect, ElOption } from 'element-plus';
import { FeedbackStatus, PollutionType, SeverityLevel, processFeedback, assignFeedback, rejectFeedback, resolveFeedback } from '@/api/feedback';
import { approveTask, rejectTask } from '@/api/tasks';
import type { UserAccount } from '@/api/types';
import apiClient from '@/api/index';
import { Document, Refresh } from '@element-plus/icons-vue';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
visible: boolean;
feedbackId: number | null;
}>();
const emit = defineEmits(['close', 'refresh']);
const feedbackStore = useFeedbackStore();
const { currentFeedbackDetail: currentFeedback, detailLoading: loading } = storeToRefs(feedbackStore);
const authStore = useAuthStore();
const currentUser = computed(() => authStore.user);
const approving = ref(false);
const rejecting = ref(false);
// 添加网格员列表状态
const gridWorkers = ref<UserAccount[]>([]);
const loadingGridWorkers = ref(false);
const selectedGridWorkerId = ref<number | null>(null);
const assignDialogVisible = ref(false);
const handleRefresh = () => {
if (props.feedbackId) {
feedbackStore.fetchFeedbackDetail(props.feedbackId);
}
};
// 加载网格员列表
const loadGridWorkers = async () => {
loadingGridWorkers.value = true;
try {
// 直接调用API
const response = await apiClient.get('/tasks/grid-workers');
// 根据apiClient的拦截器response已经是处理过的数据了
gridWorkers.value = response as unknown as UserAccount[];
console.log('成功获取到网格员列表:', gridWorkers.value);
if (gridWorkers.value.length === 0) {
ElMessage.info('当前没有可用的网格员。');
}
} catch (error) {
console.error('加载网格员列表失败:', error);
ElMessage.error('加载网格员列表失败,请检查后端服务是否正常。');
gridWorkers.value = []; // 失败时清空数组
} finally {
loadingGridWorkers.value = false;
}
};
// 获取模拟网格员数据
const getMockGridWorkers = (): UserAccount[] => {
return [
{
id: 1,
name: '张三',
email: 'zhangsan@example.com',
phone: '13800000001',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'MALE',
region: '北京市朝阳区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 2,
name: '李四',
email: 'lisi@example.com',
phone: '13800000002',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'FEMALE',
region: '北京市海淀区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 3,
name: '王五',
email: 'wangwu@example.com',
phone: '13800000003',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'MALE',
region: '北京市西城区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
];
};
// 在对话框显示时加载网格员列表
watch(
() => props.visible,
(newVisible) => {
if (newVisible && canAssign.value) {
// 移除这里的加载,改为在点击分配按钮时加载
}
}
);
// --- Computed Properties for Action Buttons ---
const canManageSubmittedTask = computed(() => {
// Per user requirements, only ADMINs can manage tasks submitted by Grid Workers.
return authStore.user?.role === 'ADMIN';
});
const isActionableBySupervisor = computed(() => {
// This property now simply checks if the user has the authority to perform
// pre-assignment actions like processing or rejecting feedback.
if (!authStore.user) return false;
const viewerRole = authStore.user.role;
return viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
});
const canProcess = computed(() => {
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
const processableStatus = [
FeedbackStatus.AI_REVIEWING,
FeedbackStatus.PENDING_REVIEW,
FeedbackStatus.CONFIRMED,
];
return processableStatus.includes(currentFeedback.value.status);
});
const canAssign = computed(() => {
if (!currentFeedback.value || !authStore.user) return false;
const viewerRole = authStore.user.role;
const isEligibleRole = viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
if (!isEligibleRole) {
return false;
}
// As per user feedback, supervisors and admins should be able to assign tasks.
// The button should appear when the feedback is ready for assignment.
// Based on the 'handleProcess' function, this status is PENDING_ASSIGNMENT.
return currentFeedback.value.status === 'PENDING_ASSIGNMENT';
});
const canReject = computed(() => {
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
const rejectableStatus = [
FeedbackStatus.AI_REVIEWING,
FeedbackStatus.PENDING_REVIEW,
];
return rejectableStatus.includes(currentFeedback.value.status);
});
// 格式化显示内容的辅助函数
const formatPollutionType = (type: string) => {
const map: Record<string, string> = {
'PM25': 'PM2.5',
'O3': '臭氧',
'NO2': '二氧化氮',
'SO2': '二氧化硫',
'OTHER': '其他'
};
return map[type] || type;
};
const formatSeverityLevel = (level: string) => {
const map: Record<string, string> = {
'LOW': '低',
'MEDIUM': '中',
'HIGH': '高'
};
return map[level] || level;
};
const formatStatus = (status: string) => {
const map: Record<string, string> = {
'AI_REVIEWING': 'AI审核中',
'PENDING_REVIEW': '待人工审核',
'REJECTED': '已拒绝',
'CONFIRMED': '已确认',
'ASSIGNED': '已分配',
'IN_PROGRESS': '处理中',
'RESOLVED': '已处理',
'CLOSED': '已关闭',
'PENDING_ASSIGNMENT': '待分配',
'PROCESSED': '已处理',
'COMPLETED': '已处理',
'SUBMITTED': '已提交'
};
return map[status] || status;
};
const getSeverityTagType = (level: string) => {
const map: Record<string, 'info' | 'warning' | 'danger'> = {
'LOW': 'info',
'MEDIUM': 'warning',
'HIGH': 'danger'
};
return map[level] || 'info';
};
const getStatusTagType = (status: string) => {
const map: Record<string, 'info' | 'warning' | 'danger' | 'primary' | 'success'> = {
'AI_REVIEWING': 'info',
'PENDING_REVIEW': 'warning',
'REJECTED': 'danger',
'CONFIRMED': 'primary',
'ASSIGNED': 'primary',
'IN_PROGRESS': 'warning',
'RESOLVED': 'success',
'CLOSED': 'info',
'PENDING_ASSIGNMENT': 'warning',
'PROCESSED': 'success',
'COMPLETED': 'success',
'SUBMITTED': 'info'
};
return map[status] || 'info';
};
const getFeedbackDisplayStatus = (feedback: any): string => {
if (!feedback) return '未知状态';
if (feedback.task && feedback.task.status === 'COMPLETED') {
return '已处理';
}
return formatStatus(feedback.status);
};
const getFeedbackDisplayTagType = (feedback: any): 'info' | 'warning' | 'danger' | 'primary' | 'success' => {
if (!feedback) return 'info';
if (feedback.task && feedback.task.status === 'COMPLETED') {
return 'success';
}
return getStatusTagType(feedback.status);
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleString();
} catch (e) {
return dateStr;
}
};
const formatRole = (role: string) => {
const map: Record<string, string> = {
'ADMIN': '管理员',
'DECISION_MAKER': '决策人',
'SUPERVISOR': '监督员',
'GRID_WORKER': '网格员',
'PUBLIC_SUPERVISOR': '公众监督员'
};
return map[role] || role;
};
watch(
() => props.feedbackId,
(newId) => {
if (newId && props.visible) {
feedbackStore.fetchFeedbackDetail(newId);
}
},
{ immediate: true }
);
const handleClose = () => {
// 清空选中的网格员
selectedGridWorkerId.value = null;
emit('close');
};
const handleProcess = async () => {
if (!currentFeedback.value) return;
try {
await ElMessageBox.confirm(
'此操作将批准反馈并创建一个待分配的任务。',
'确认处理反馈',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'success',
}
);
await processFeedback(currentFeedback.value.id, {
status: FeedbackStatus.PENDING_ASSIGNMENT,
notes: '管理员已批准该反馈,准备分配任务。'
});
ElMessage.success('反馈已批准,进入待分配状态');
emit('refresh');
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('处理反馈失败');
}
}
};
const handleAssign = () => {
if (!currentFeedback.value) return;
loadGridWorkers();
assignDialogVisible.value = true;
};
const confirmAssign = async () => {
if (!currentFeedback.value || !selectedGridWorkerId.value) {
ElMessage.warning('请选择一个网格员');
return;
}
try {
await apiClient.post('/tasks/assign', {
feedbackId: currentFeedback.value.id,
assigneeId: selectedGridWorkerId.value
});
ElMessage.success('分配成功');
assignDialogVisible.value = false;
emit('refresh');
handleClose();
} catch (apiError) {
console.error('API分配失败:', apiError);
ElMessage.error('分配失败,请稍后重试');
}
};
const handleRejectTask = async () => {
const taskId = currentFeedback.value?.task?.id;
if (!taskId) {
ElMessage.error('无法获取任务ID操作取消');
return;
}
ElMessageBox.prompt('请输入打回任务的理由:', '打回任务', {
confirmButtonText: '确认打回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '理由不能为空',
})
.then(async ({ value }) => {
rejecting.value = true;
try {
await rejectTask(taskId, value);
ElMessage.success('任务已打回');
emit('refresh');
handleClose();
} catch (error) {
console.error('打回任务失败:', error);
ElMessage.error('操作失败,请重试');
} finally {
rejecting.value = false;
}
})
.catch(() => {
ElMessage.info('已取消操作');
});
};
const handleApprove = async () => {
if (!currentFeedback.value?.task) return;
approving.value = true;
try {
await approveTask(currentFeedback.value.task.id);
ElMessage.success('任务已批准');
emit('refresh');
} catch (error) {
ElMessage.error('批准任务失败');
} finally {
approving.value = false;
}
};
const handleRejectFeedback = async () => {
if (!currentFeedback.value) return;
if (props.feedbackId) {
ElMessageBox.prompt('请输入拒绝此反馈的理由:', '拒绝反馈', {
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
})
.then(async ({ value }) => {
try {
await rejectFeedback(props.feedbackId!, { notes: value || '无明确理由' });
ElMessage.success('反馈已拒绝');
emit('refresh');
handleClose();
} catch (error) {
console.error('拒绝反馈失败:', error);
ElMessage.error('操作失败');
}
})
.catch(() => {
ElMessage.info('已取消拒绝操作');
});
}
};
</script>
<style scoped>
.feedback-detail-dialog .loading-container {
height: 300px;
}
.detail-container {
max-height: 75vh;
overflow-y: auto;
}
.mt-4 {
margin-top: 1.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.description-content {
white-space: pre-wrap;
}
.image-gallery {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.feedback-image {
width: 100px;
height: 100px;
border-radius: 4px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<div class="grid-map-container">
<div class="map-header">
<h3>环境监测网格地图</h3>
<div class="map-controls">
<el-select v-model="selectedCity" placeholder="选择城市" @change="handleCityChange" size="small">
<el-option v-for="city in cities" :key="city" :label="city" :value="city" />
</el-select>
<el-button type="primary" size="small" :icon="Loading" :loading="loading" @click="refreshData">
刷新数据
</el-button>
</div>
</div>
<div class="map-content">
<div class="map-container" ref="mapContainer">
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon" :size="32"><Loading /></el-icon>
<div class="loading-text">加载中...</div>
</div>
</div>
<div class="map-legend">
<div v-for="city in Object.keys(cityColorPalette)" :key="city" class="legend-item">
<div class="legend-color" :style="{ backgroundColor: cityColorPalette[city] }"></div>
<span>{{ city }}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #333333;"></div>
<span>障碍区域</span>
</div>
</div>
<!-- 网格编辑/详情一体化对话框 -->
<el-dialog
v-model="gridEditDialogVisible"
:title="editingGrid ? `编辑网格 (${editingGrid.gridX}, ${editingGrid.gridY})` : '网格详情'"
width="600px"
destroy-on-close
>
<el-form v-if="editingGrid" :model="editFormData" label-width="120px">
<el-descriptions :column="1" border class="detail-view" v-if="!isEditMode">
<el-descriptions-item label="城市">{{ editingGrid.cityName }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ editingGrid.districtName || '未指定' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="editingGrid.isObstacle ? 'danger' : currentWorkerInDialog ? 'success' : 'warning'" effect="dark">
{{ editingGrid.isObstacle ? '障碍区域' : currentWorkerInDialog ? '已分配' : '未分配' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ editFormData.description || '无' }}</el-descriptions-item>
<el-descriptions-item label="网格员">
<div v-if="currentWorkerInDialog">
<strong>{{ currentWorkerInDialog.name }}</strong> (ID: {{ currentWorkerInDialog.id }})
</div>
<div v-else>未分配</div>
</el-descriptions-item>
</el-descriptions>
<div v-if="isEditMode">
<el-form-item label="描述">
<el-input v-model="editFormData.description" type="textarea" :rows="3" placeholder="请输入网格描述" />
</el-form-item>
<el-form-item label="是否为障碍物">
<el-switch v-model="editFormData.isObstacle" />
</el-form-item>
<div v-if="!editFormData.isObstacle">
<el-divider>网格员分配</el-divider>
<el-form-item label="分配网格员">
<el-select v-model="editFormData.workerId" placeholder="选择或搜索网格员" clearable filterable style="width: 100%;">
<el-option
v-for="worker in availableGridWorkers"
:key="worker.id"
:label="`${worker.name} (ID: ${worker.id})`"
:value="worker.id"
/>
</el-select>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="isEditMode = !isEditMode">{{ isEditMode ? '返回详情' : '编辑网格' }}</el-button>
<el-button @click="gridEditDialogVisible = false">关闭</el-button>
<el-button v-if="isEditMode" type="primary" @click="handleUpdateGrid" :loading="loading">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import {
getGrids,
getGridWorkers,
updateGrid,
assignGridWorkerByCoordinates,
removeGridWorkerByCoordinates
} from '@/api/grid';
import type { GridData, GridWorker } from '@/api/grid';
import {
ElMessage,
ElDialog,
ElDescriptions,
ElDescriptionsItem,
ElButton,
ElDivider,
ElTag,
ElSelect,
ElOption,
ElForm,
ElFormItem,
ElInput,
ElSwitch,
ElAlert
} from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
const loading = ref(false);
const selectedCity = ref<string>('北京市');
const cities = ref<string[]>(['北京市']);
const mapContainer = ref<HTMLDivElement | null>(null);
const gridData = ref<GridData[]>([]);
const gridWorkers = ref<GridWorker[]>([]);
const availableGridWorkers = ref<GridWorker[]>([]);
const gridEditDialogVisible = ref(false);
const editingGrid = ref<GridData | null>(null);
const isEditMode = ref(false);
const editFormData = ref({
description: '',
isObstacle: false,
workerId: null as number | string | null,
});
const currentWorkerInDialog = computed(() => {
if (!editingGrid.value) return null;
return gridWorkers.value.find(w => w.gridX === editingGrid.value!.gridX && w.gridY === editingGrid.value!.gridY) || null;
});
let gridSize = 20;
const cityColorPalette: Record<string, string> = {};
const getCityColor = (cityName: string): string => {
if (!cityColorPalette[cityName]) {
const predefinedColors = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
const allCityNames = [...new Set(gridData.value.map(g => g.cityName))];
const cityIndex = allCityNames.indexOf(cityName);
cityColorPalette[cityName] = predefinedColors[cityIndex % predefinedColors.length];
}
return cityColorPalette[cityName];
};
const loadData = async () => {
loading.value = true;
try {
const [grids, workers] = await Promise.all([
getGrids(),
getGridWorkers()
]);
if (Array.isArray(grids)) {
gridData.value = grids;
const uniqueCities = [...new Set(grids.map(g => g.cityName))];
if (uniqueCities.length > 0) {
cities.value = uniqueCities;
selectedCity.value = uniqueCities[0];
}
}
if (Array.isArray(workers)) {
gridWorkers.value = workers;
}
renderMap();
} catch (error) {
console.error('加载数据失败:', error);
ElMessage.error('加载数据失败,请检查后端服务');
} finally {
loading.value = false;
}
};
const fetchAvailableGridWorkers = async () => {
try {
const response = await getGridWorkers({ unassigned: true });
availableGridWorkers.value = Array.isArray(response) ? response : [];
} catch (error) {
console.error('获取可用网格员数据失败:', error);
ElMessage.error('获取可用网格员列表失败');
}
};
const renderMap = () => {
const container = mapContainer.value;
if (!container || !gridData.value || gridData.value.length === 0) {
if (container) container.innerHTML = '<div class="empty-state">没有可显示的网格数据</div>';
return;
}
container.innerHTML = '';
const allX = gridData.value.map(g => g.gridX);
const allY = gridData.value.map(g => g.gridY);
const minX = Math.min(...allX);
const maxX = Math.max(...allX);
const minY = Math.min(...allY);
const maxY = Math.max(...allY);
const worldWidth = maxX - minX + 1;
const worldHeight = maxY - minY + 1;
gridSize = Math.max(5, Math.floor(Math.min(container.clientWidth / worldWidth, container.clientHeight / worldHeight)));
const svgWidth = worldWidth * gridSize;
const svgHeight = worldHeight * gridSize;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', `${svgWidth}px`);
svg.setAttribute('height', `${svgHeight}px`);
svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
gridData.value.forEach(grid => {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', `${(grid.gridX - minX) * gridSize}`);
rect.setAttribute('y', `${(grid.gridY - minY) * gridSize}`);
rect.setAttribute('width', `${gridSize}`);
rect.setAttribute('height', `${gridSize}`);
rect.setAttribute('fill', grid.isObstacle ? '#333333' : getCityColor(grid.cityName));
rect.setAttribute('stroke-width', '0.5');
rect.setAttribute('stroke', '#fff');
rect.setAttribute('cursor', 'pointer');
rect.addEventListener('click', () => showGridDialog(grid));
svg.appendChild(rect);
});
container.appendChild(svg);
};
const showGridDialog = async (grid: GridData) => {
isEditMode.value = false;
editingGrid.value = grid;
const currentWorker = gridWorkers.value.find(w => w.gridX === grid.gridX && w.gridY === grid.gridY);
editFormData.value = {
description: grid.description || '',
isObstacle: grid.isObstacle,
workerId: currentWorker ? currentWorker.id : '',
};
await fetchAvailableGridWorkers();
gridEditDialogVisible.value = true;
};
const handleUpdateGrid = async () => {
if (!editingGrid.value) return;
loading.value = true;
const grid = editingGrid.value;
const { isObstacle, description, workerId } = editFormData.value;
const newWorkerId = workerId ? Number(workerId) : null;
try {
await updateGrid({ ...grid, isObstacle, description });
const workerAssignmentChanged = (currentWorkerInDialog.value?.id ?? null) !== newWorkerId;
if (isObstacle && currentWorkerInDialog.value) {
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
} else if (!isObstacle && workerAssignmentChanged) {
if (newWorkerId) {
await assignGridWorkerByCoordinates(grid.gridX, grid.gridY, newWorkerId);
} else if (currentWorkerInDialog.value) {
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
}
}
ElMessage.success('网格信息更新成功');
gridEditDialogVisible.value = false;
await loadData();
} catch (error: any) {
ElMessage.error('更新网格失败: ' + (error?.response?.data?.message || error.message));
} finally {
loading.value = false;
}
};
const refreshData = () => loadData();
const handleCityChange = () => { /* Future implementation for city-specific view */ };
onMounted(() => {
loadData();
window.addEventListener('resize', renderMap);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', renderMap);
});
</script>
<style scoped>
.grid-map-container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border-radius: 8px;
}
.map-header {
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
}
.map-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 15px;
overflow: hidden;
}
.map-container {
flex: 1;
position: relative;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: auto;
}
.map-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
padding-top: 15px;
}
.legend-item { display: flex; align-items: center; }
.legend-color { width: 16px; height: 16px; margin-right: 8px; border-radius: 4px; }
.dialog-footer { text-align: right; }
.detail-view { margin-bottom: 20px; }
</style>

View File

@@ -0,0 +1,99 @@
<template>
<el-dialog
:model-value="visible"
title="提交任务进度"
width="50%"
@update:model-value="$emit('update:visible', $event)"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" label-width="80px">
<el-form-item label="进度说明" prop="comments">
<el-input
v-model="form.comments"
type="textarea"
rows="4"
placeholder="请输入详细的进度说明..."
/>
</el-form-item>
<el-form-item label="上传图片" prop="files">
<el-upload
v-model:file-list="form.files"
action="#"
list-type="picture-card"
:auto-upload="false"
multiple
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
提交
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import type { FormInstance, UploadUserFile } from 'element-plus';
import { ElMessage } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import apiClient from '@/api';
const props = defineProps<{
visible: boolean;
taskId: number | null;
}>();
const emit = defineEmits(['update:visible', 'submitted']);
const formRef = ref<FormInstance>();
const loading = ref(false);
const form = reactive({
comments: '',
files: [] as UploadUserFile[],
});
watch(() => props.visible, (newValue) => {
if (newValue) {
formRef.value?.resetFields();
}
});
const handleSubmit = async () => {
if (!props.taskId) {
ElMessage.error('任务ID无效');
return;
}
loading.value = true;
try {
const formData = new FormData();
formData.append('comments', form.comments);
form.files.forEach(file => {
if (file.raw) {
formData.append('files', file.raw);
}
});
await apiClient.post(`/worker/${props.taskId}/submit`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
ElMessage.success('进度提交成功');
emit('submitted');
emit('update:visible', false);
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('提交失败');
} finally {
loading.value = false;
}
};
</script>

View File

@@ -0,0 +1,27 @@
/**
* @file Pollution related constants, aligned with backend enums.
*/
/**
* Represents the types of pollution, consistent with the `PollutionType` enum in the backend.
* This ensures that frontend displays and data submissions are aligned with backend expectations.
*/
export const POLLUTION_TYPES = ['PM25', 'O3', 'NO2', 'SO2', 'OTHER'] as const;
/**
* Type definition for a single pollution type.
* Ensures type safety when working with pollution constants.
*/
export type PollutionType = typeof POLLUTION_TYPES[number];
/**
* A map to provide human-readable names for pollution types.
* Useful for displaying in UI components like chart legends or labels.
*/
export const POLLUTION_TYPE_MAP: Record<PollutionType, string> = {
PM25: 'PM2.5',
O3: 'O₃',
NO2: 'NO₂',
SO2: 'SO₂',
OTHER: '其他',
};

View File

@@ -0,0 +1,428 @@
<template>
<el-container class="layout-container" :class="layoutClass">
<el-header class="layout-header">
<div class="header-title">环境监督系统</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
{{ userInfoDisplay }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-container class="main-body-container">
<el-aside width="220px" class="layout-aside">
<el-menu
:default-active="$route.path"
class="el-menu-vertical"
@select="handleMenuSelect"
>
<template v-for="menu in visibleMenu" :key="menu.path">
<el-sub-menu v-if="menu.children && menu.children.length > 0" :index="menu.path">
<template #title>
<el-icon><component :is="menu.meta.icon" /></el-icon>
<span>{{ menu.meta.title }}</span>
</template>
<el-menu-item v-for="child in menu.children" :key="child.path" :index="child.path">
{{ child.meta.title }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="menu.path">
<el-icon><component :is="menu.meta.icon" /></el-icon>
<span>{{ menu.meta.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import {
HomeFilled,
InfoFilled,
ArrowDown,
Location,
List,
ChatDotSquare,
Setting,
User,
DataAnalysis,
Grid,
} from '@element-plus/icons-vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth';
import { computed, ref, markRaw, watch } from 'vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const userRole = computed(() => authStore.user?.role);
const layoutClass = computed(() => {
if (userRole.value) {
// a simple convention would be `role-<role-name-in-lowercase>`
return `role-${userRole.value.toLowerCase()}`;
}
return '';
});
watch(
() => route.path,
(newPath, oldPath) => {
console.log(`路由从 ${oldPath} 切换到 ${newPath}`);
}
);
const allMenus = ref([
{
path: '/dashboard',
meta: { title: '仪表盘', icon: markRaw(DataAnalysis), roles: ['DECISION_MAKER', 'ADMIN'] }
},
{
path: '/map',
meta: { title: '网格地图', icon: markRaw(Grid), roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER'] }
},
{
path: '/my-tasks',
meta: { title: '我的任务', icon: markRaw(List), roles: ['GRID_WORKER'] }
},
{
path: '/worker-map',
meta: { title: '地图管理', icon: markRaw(Grid), roles: ['GRID_WORKER'] }
},
{
path: '/submit-feedback',
meta: { title: '提交反馈', icon: markRaw(ChatDotSquare), roles: ['PUBLIC_SUPERVISOR'] }
},
{
path: '/feedback',
meta: { title: '我的反馈', icon: markRaw(List), roles: ['PUBLIC_SUPERVISOR'] }
},
{
path: '/feedback',
meta: { title: '反馈管理', icon: markRaw(ChatDotSquare), roles: ['ADMIN', 'SUPERVISOR'] }
},
{
path: '/system',
meta: { title: '系统管理', icon: markRaw(Setting), roles: ['ADMIN'] },
children: [
{ path: '/system/users', meta: { title: '人员管理', roles: ['ADMIN'] } },
{ path: '/system/roles', meta: { title: '操作日志', roles: ['ADMIN'] } },
]
},
{
path: '/settings',
meta: { title: '设置', icon: markRaw(User), roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] },
children: [
{ path: '/settings/profile', meta: { title: '个人中心', roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] } },
{ path: '/settings/my-logs', meta: { title: '我的操作日志', roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] } },
{ path: '/settings/system', meta: { title: '系统设置', roles: ['ADMIN'] } },
]
},
]);
const visibleMenu = computed(() => {
if (!userRole.value) {
return [];
}
const role = userRole.value;
function filterMenu(menuItems: any[]) {
const accessibleMenu: any[] = [];
for (const item of menuItems) {
if (item.meta && item.meta.roles && item.meta.roles.includes(role)) {
const newItem = { ...item };
if (item.children) {
newItem.children = filterMenu(item.children);
if (newItem.children.length > 0 || newItem.path === '/settings') {
accessibleMenu.push(newItem);
}
} else {
accessibleMenu.push(newItem);
}
}
}
return accessibleMenu;
}
return filterMenu(allMenus.value);
});
const userInfoDisplay = computed(() => {
if (authStore.user) {
return `${authStore.user.name} (${authStore.user.role})`;
}
return '未登录';
});
const handleCommand = (command: string | number | object) => {
if (command === 'logout') {
authStore.logout();
router.push('/login');
}
}
const handleMenuSelect = (path: string) => {
console.log('菜单选择:', path);
if (route.path === '/grid-management' && path !== '/grid-management') {
console.log('从网格管理页面切换到其他页面使用location.href');
window.location.href = path;
} else {
router.push(path);
}
};
</script>
<style scoped>
.layout-container {
height: 100vh;
display: flex;
flex-direction: column;
/* The background color will be set by role-specific styles */
}
.layout-header {
/* background: #ffffff; */ /* Will be set by role-specific styles */
color: #303133;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 22px;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
z-index: 10;
flex-shrink: 0;
}
.header-title {
font-weight: 600;
letter-spacing: 1px;
/* color: #004d40; */ /* Will be set by role-specific styles */
}
.header-right {
display: flex;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
font-size: 16px;
}
.main-body-container {
flex: 1;
overflow: hidden;
}
.layout-aside {
/* background-color: #ffffff; */ /* Will be set by role-specific styles */
box-shadow: 2px 0 6px rgba(0,21,41,.08);
transition: width 0.3s;
border-right: 1px solid #e6e6e6;
overflow-y: auto;
}
.el-menu {
border-right: none;
background-color: transparent;
}
.el-menu-item, .el-sub-menu__title {
/* color: #303133; */ /* Will be set by role-specific styles */
font-size: 15px;
height: 50px;
line-height: 50px;
}
.el-menu-item:hover, .el-sub-menu__title:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.el-menu-item.is-active {
/* background-color: #ecf5ff; */ /* Will be set by role-specific styles */
font-weight: bold;
}
.layout-main {
padding: 20px;
background-color: #f0f2f5;
flex: 1;
overflow-y: auto;
position: relative;
}
/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
<style>
/* Role-based theming */
/* ADMIN THEME (Dark Green) */
.role-admin .layout-header {
background-color: #2E7D32; /* Dark Green */
color: white;
}
.role-admin .header-title {
color: white;
}
.role-admin .el-dropdown-link {
color: white;
}
.role-admin .layout-aside {
background-color: #388E3C; /* Slightly lighter green */
}
.role-admin .el-menu-item,
.role-admin .el-sub-menu__title {
color: #E8F5E9; /* Muted green text */
}
.role-admin .el-menu-item:hover,
.role-admin .el-sub-menu__title:hover {
background-color: #45a049;
}
.role-admin .el-menu-item.is-active {
background-color: #2E7D32; /* Dark Green */
color: #fff;
}
/* DECISION_MAKER THEME (Corporate Green) */
.role-decision_maker .layout-header {
background-color: #00695C; /* Teal/Corporate Green */
color: white;
}
.role-decision_maker .header-title {
color: white;
}
.role-decision_maker .el-dropdown-link {
color: white;
}
.role-decision_maker .layout-aside {
background-color: #00796B;
}
.role-decision_maker .el-menu-item,
.role-decision_maker .el-sub-menu__title {
color: #B2DFDB;
}
.role-decision_maker .el-menu-item:hover,
.role-decision_maker .el-sub-menu__title:hover {
background-color: #00897b;
}
.role-decision_maker .el-menu-item.is-active {
background-color: #00695C;
color: #fff;
}
/* GRID_WORKER THEME (Vibrant Green) */
.role-grid_worker .layout-header {
background-color: #689F38; /* Vibrant Green */
color: white;
}
.role-grid_worker .header-title {
color: white;
}
.role-grid_worker .el-dropdown-link {
color: white;
}
.role-grid_worker .layout-aside {
background-color: #7CB342;
}
.role-grid_worker .el-menu-item,
.role-grid_worker .el-sub-menu__title {
color: #F1F8E9;
}
.role-grid_worker .el-menu-item:hover,
.role-grid_worker .el-sub-menu__title:hover {
background-color: #8bc34a;
}
.role-grid_worker .el-menu-item.is-active {
background-color: #689F38;
color: #fff;
}
/* SUPERVISOR THEME (Calming Green) */
.role-supervisor .layout-header {
background-color: #39796b; /* Calming Green */
color: white;
}
.role-supervisor .header-title {
color: white;
}
.role-supervisor .el-dropdown-link {
color: white;
}
.role-supervisor .layout-aside {
background-color: #4DB6AC;
}
.role-supervisor .el-menu-item,
.role-supervisor .el-sub-menu__title {
color: #E0F2F1;
}
.role-supervisor .el-menu-item:hover,
.role-supervisor .el-sub-menu__title:hover {
background-color: #26a69a;
}
.role-supervisor .el-menu-item.is-active {
background-color: #39796b;
color: #fff;
}
/* PUBLIC_SUPERVISOR THEME (Light Green) */
.role-public_supervisor .layout-header {
background-color: #AED581; /* Light Green */
color: #333;
}
.role-public_supervisor .header-title {
color: #333;
}
.role-public_supervisor .el-dropdown-link {
color: #333;
}
.role-public_supervisor .layout-aside {
background-color: #DCEDC8;
}
.role-public_supervisor .el-menu-item,
.role-public_supervisor .el-sub-menu__title {
color: #556B2F;
}
.role-public_supervisor .el-menu-item:hover,
.role-public_supervisor .el-sub-menu__title:hover {
background-color: #9ccc65;
}
.role-public_supervisor .el-menu-item.is-active {
background-color: #AED581;
color: #333;
}
</style>

View File

@@ -0,0 +1,31 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './assets/main.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'animate.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
zIndex: 3000,
popperOptions: {
modifiers: [
{
name: 'computeStyles',
options: {
gpuAcceleration: false
}
}
]
}
})
app.mount('#app')

View File

@@ -0,0 +1,163 @@
import { createRouter, createWebHistory } from 'vue-router'
import MainLayout from '../layouts/MainLayout.vue'
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue')
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue')
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('../views/ForgotPasswordView.vue')
},
{
path: '/reset-password',
name: 'reset-password',
component: () => import('../views/ResetPasswordView.vue')
},
{
path: '/public-feedback',
name: 'public-feedback',
component: () => import('../views/PublicFeedbackSubmitView.vue')
},
{
path: '/',
component: MainLayout,
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('../views/HomeView.vue'),
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR'] }
},
{
path: 'map',
name: 'map',
component: () => import('../views/GridManagementView.vue'),
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER'] }
},
{
path: 'tasks',
name: 'tasks',
component: () => import('../views/TaskView.vue'),
meta: { roles: ['ADMIN', 'SUPERVISOR'] }
},
{
path: 'my-tasks',
name: 'my-tasks',
component: () => import('../views/MyTasksView.vue'),
meta: { roles: ['GRID_WORKER'] }
},
{
path: 'worker-map',
name: 'worker-map',
component: () => import('../views/WorkerMapView.vue'),
meta: { requiresAuth: true, roles: ['GRID_WORKER'] }
},
{
path: 'my-tasks/:id',
name: 'my-task-detail',
component: () => import('../views/TaskDetailView.vue'),
props: true,
meta: { roles: ['GRID_WORKER'] }
},
{
path: 'feedback',
name: 'feedback',
component: () => import('../views/FeedbackView.vue'),
meta: { roles: ['PUBLIC_SUPERVISOR', 'ADMIN', 'SUPERVISOR'] }
},
{
path: 'submit-feedback',
name: 'submit-feedback',
component: () => import('../views/SubmitFeedbackView.vue'),
meta: { roles: ['PUBLIC_SUPERVISOR'] }
},
{
path: 'system/users',
name: 'system-users',
component: () => import('../views/UserManagementView.vue'),
meta: { roles: ['ADMIN'] }
},
{
path: 'system/roles',
name: 'system-roles',
component: () => import('../views/OperationLogView.vue'),
meta: { roles: ['ADMIN'] }
},
{
path: 'settings/system',
name: 'settings-system',
component: () => import('../views/SystemSettingsView.vue'),
meta: { roles: ['ADMIN'] }
},
{
path: 'settings/profile',
name: 'settings-profile',
component: () => import('../views/UserProfileView.vue'),
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] }
},
{
path: 'settings/my-logs',
name: 'settings-my-logs',
component: () => import('../views/MyOperationLogView.vue'),
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] }
},
{
path: 'about',
name: 'about',
component: () => import('../views/AboutView.vue')
}
]
}
]
})
router.beforeEach((to, from, next) => {
const publicPages = ['/login', '/register', '/forgot-password', '/reset-password', '/public-feedback'];
const authRequired = !publicPages.includes(to.path);
const authStore = useAuthStore();
if (authRequired && !authStore.isLoggedIn) {
return next('/login');
}
// 检查路由是否需要特定角色
if (to.meta.roles) {
const userRole = authStore.user?.role;
if (userRole && (to.meta.roles as string[]).includes(userRole)) {
next();
} else {
// 如果用户角色不被允许,可以重定向到 403 页面或主页
// 这里我们简单地重定向到用户各自的主页
if(userRole) {
switch (userRole) {
case 'GRID_WORKER':
return next('/my-tasks');
case 'PUBLIC_SUPERVISOR':
return next('/submit-feedback');
default:
return next('/dashboard');
}
}
return next('/login'); // 如果没有角色信息,则返回登录页
}
} else {
// 如果路由没有 meta.roles则允许所有已登录用户访问
next();
}
});
export default router

View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,124 @@
import { defineStore } from 'pinia';
import {
getFeedbackList,
getFeedbackDetail,
processFeedback,
assignFeedback,
resolveFeedback,
closeFeedback,
rejectFeedback,
confirmFeedback,
getFeedbackStats,
submitFeedback,
FeedbackStatus
} from '@/api/feedback';
import type {
FeedbackResponse,
FeedbackDetail,
FeedbackFilters,
Page
} from '@/api/feedback';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '../stores/auth';
interface FeedbackState {
feedbackList: FeedbackResponse[];
total: number;
loading: boolean;
currentFeedbackDetail: FeedbackDetail | null;
detailLoading: boolean;
stats: {
total: number;
pending: number;
confirmed: number;
assigned: number;
inProgress: number;
resolved: number;
closed: number;
rejected: number;
};
}
export const useFeedbackStore = defineStore('feedback', {
state: (): FeedbackState => ({
feedbackList: [],
total: 0,
loading: false,
currentFeedbackDetail: null,
detailLoading: false,
stats: {
total: 0,
pending: 0,
confirmed: 0,
assigned: 0,
inProgress: 0,
resolved: 0,
closed: 0,
rejected: 0
}
}),
actions: {
async fetchFeedbackList(params: FeedbackFilters) {
this.loading = true;
const authStore = useAuthStore();
const user = authStore.user;
let apiParams: FeedbackFilters = { ...params };
if (user && user.role === 'PUBLIC_SUPERVISOR') {
apiParams.submitterId = user.id;
}
try {
const response = await getFeedbackList(apiParams);
this.feedbackList = response.content;
this.total = response.totalElements;
} catch (error) {
console.error('获取反馈列表失败:', error);
ElMessage.error('获取反馈列表失败,请检查网络或联系管理员');
this.feedbackList = [];
this.total = 0;
} finally {
this.loading = false;
}
},
async fetchFeedbackDetail(id: number) {
this.detailLoading = true;
try {
this.currentFeedbackDetail = await getFeedbackDetail(id);
} catch (error) {
console.error('获取反馈详情失败:', error);
ElMessage.error('无法加载反馈详情');
this.currentFeedbackDetail = null;
} finally {
this.detailLoading = false;
}
},
async fetchFeedbackStats() {
try {
const statsData = await getFeedbackStats();
this.stats = { ...this.stats, ...statsData };
} catch (error) {
console.error('获取反馈统计数据API错误:', error);
ElMessage.error('获取反馈统计数据失败');
}
},
async submitFeedback(formData: FormData) {
try {
await submitFeedback(formData);
this.fetchFeedbackStats();
} catch (error) {
console.error('提交反馈失败:', error);
throw error;
}
},
// 可以在这里添加更多处理反馈的 actions, 例如:
// async process(id: number, data: ProcessFeedbackRequest) { ... }
// async assign(id: number, data: AssignFeedbackRequest) { ... }
}
});

View File

@@ -0,0 +1,127 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { jwtDecode } from 'jwt-decode';
import type { LoginRequest, User } from '@/api/types';
import apiClient from '@/api';
import { loginWithFetch, logout as apiLogout } from '@/api/auth';
import router from '@/router'; // 导入 Vue Router 实例
// 定义从JWT解码出的负载的类型, 它继承自 User 类型
interface JwtPayload extends User {
iat: number; // Issued At
exp: number; // Expiration Time
sub: string; // Subject, typically the user's email or ID
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'));
const user = ref<User | null>(null);
/**
* 从 token 解码并设置 user state
* @param tokenValue - JWT token string
*/
function setUserFromToken(tokenValue: string) {
try {
const decoded = jwtDecode<JwtPayload>(tokenValue);
// 解码出的信息直接赋值给 user state
user.value = decoded;
} catch (error) {
console.error('Failed to decode token or token is invalid:', error);
// 解码失败时, 清理状态
user.value = null;
token.value = null;
localStorage.removeItem('token');
}
}
// 初始化时如果localStorage中存在 token则从中恢复用户信息
if (token.value) {
setUserFromToken(token.value);
}
const isLoggedIn = computed(() => !!token.value && !!user.value);
/**
* 设置认证信息
* @param newToken - JWT
*/
function setAuth(newToken: string) {
token.value = newToken;
localStorage.setItem('token', newToken);
setUserFromToken(newToken);
}
/**
* 用户登录
* @param credentials - 登录凭据
*/
async function login(credentials: LoginRequest) {
try {
const response = await loginWithFetch(credentials);
const newToken = response.accessToken;
if (!newToken) {
throw new Error('accessToken not found in login response');
}
setAuth(newToken);
// 登录成功后,根据用户角色进行重定向
if (user.value) {
switch (user.value.role) {
case 'GRID_WORKER':
router.push('/my-tasks');
break;
case 'ADMIN':
case 'DECISION_MAKER':
router.push('/dashboard');
break;
case 'SUPERVISOR':
case 'PUBLIC_SUPERVISOR':
router.push('/feedback');
break;
default:
// 对于其他未知角色,可以重定向到登录页或一个通用的欢迎页
router.push('/login');
break;
}
} else {
// 如果用户信息未设置,则默认重定向到主页
router.push('/');
}
} catch (error: any) {
console.error('Login process failed:', error);
delete apiClient.defaults.headers.common['Authorization'];
throw error;
}
}
/**
* 用户登出
*/
async function logout() {
try {
// 调用后端的登出接口以记录日志
await apiLogout();
} catch (error) {
console.error('Failed to call logout API, but proceeding with client-side logout:', error);
} finally {
// 无论后端调用是否成功,都清理前端状态
user.value = null;
token.value = null;
localStorage.removeItem('token');
// 登出后重定向到登录页
router.push('/login');
}
}
return {
token,
user,
isLoggedIn,
login,
logout,
};
});

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,20 @@
import { format } from 'date-fns';
/**
* Formats a date-time string or Date object into a standardized "yyyy-MM-dd HH:mm:ss" format.
* If the input is invalid or null, it returns an empty string.
*
* @param dateTime The date-time to format (string, Date, or null/undefined).
* @returns The formatted date-time string or an empty string.
*/
export function formatDateTime(dateTime: string | Date | null | undefined): string {
if (!dateTime) {
return '';
}
try {
return format(new Date(dateTime), 'yyyy-MM-dd HH:mm:ss');
} catch (error) {
console.error('Error formatting date:', error);
return '';
}
}

View File

@@ -0,0 +1,12 @@
<template>
<div>
<h1>关于页面</h1>
<p>这是一个使用 Vue 3 Element Plus 构建的环境监督系统</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div class="feedback-management-page">
<!-- 统计面板 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="4">
<el-card shadow="hover" class="stats-card">
<div class="stats-item">
<div class="stats-value">{{ stats.total }}</div>
<div class="stats-label">总反馈</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stats-card pending">
<div class="stats-item">
<div class="stats-value">{{ stats.pending }}</div>
<div class="stats-label">待处理</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stats-card confirmed">
<div class="stats-item">
<div class="stats-value">{{ stats.confirmed }}</div>
<div class="stats-label">已确认</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stats-card assigned">
<div class="stats-item">
<div class="stats-value">{{ stats.assigned }}</div>
<div class="stats-label">已分配</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stats-card resolved">
<div class="stats-item">
<div class="stats-value">{{ stats.resolved }}</div>
<div class="stats-label">已解决</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stats-card rejected">
<div class="stats-item">
<div class="stats-value">{{ stats.rejected }}</div>
<div class="stats-label">已拒绝</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="page-card">
<template #header>
<div class="card-header">
<span>反馈管理</span>
</div>
</template>
<!-- 筛选区域 -->
<div class="filter-container">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="请选择状态" clearable>
<el-option
v-for="item in filterOptions.status"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="污染类型">
<el-select v-model="filters.pollutionType" placeholder="请选择污染类型" clearable>
<el-option
v-for="item in filterOptions.pollutionType"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="严重程度">
<el-select
v-model="filters.severityLevel"
placeholder="所有程度"
clearable
:popper-append-to-body="false"
>
<template #default>
<el-option
v-for="(text, key) in severityLevelMap"
:key="key"
:label="text"
:value="key"
/>
</template>
<template #prefix>{{ selectedSeverityLabel }}</template>
</el-select>
</el-form-item>
<el-form-item label="提交时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="标题、描述、报告人" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 反馈列表 -->
<el-table :data="feedbackList" v-loading="loading" stripe class="feedback-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTagMap[scope.row.status] || 'info'">
{{ statusMap[scope.row.status] || '未知状态' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="pollutionType" label="污染类型" width="120">
<template #default="{ row }: { row: FeedbackResponse }">
<span>{{ pollutionTypeMap[row.pollutionType] }}</span>
</template>
</el-table-column>
<el-table-column prop="severityLevel" label="严重程度" width="100">
<template #default="{ row }: { row: FeedbackResponse }">
<el-tag :type="severityTagMap[row.severityLevel]">{{ severityLevelMap[row.severityLevel] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="网格坐标" width="120">
<template #default="{ row }: { row: FeedbackResponse }">
<span v-if="row.gridX !== null && row.gridY !== null">
{{ `X: ${row.gridX}, Y: ${row.gridY}` }}
</span>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="处理人" width="120">
<template #default="{ row }: { row: FeedbackResponse }">
<span v-if="row.task && row.task.assignee">
{{ row.task.assignee.name }}
</span>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column prop="user.name" label="报告人" width="120">
<template #default="{ row }: { row: FeedbackResponse }">
<div>
<span>{{ row.user ? row.user.name : '未知' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180">
<template #default="{ row }">
<span>{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleViewDetails(row)">查看详情</el-button>
<!-- 其他操作按钮如处理分配等 -->
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 详情对话框 -->
<FeedbackDetailDialog
:visible="detailDialogVisible"
:feedback-id="selectedFeedbackId"
@close="detailDialogVisible = false"
@refresh="fetchFeedbackList"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue';
import { useFeedbackStore } from '@/store/feedback';
import { storeToRefs } from 'pinia';
import { FeedbackStatus, SeverityLevel } from '@/api/feedback';
import { PollutionType } from '@/api/types';
import type { FeedbackResponse, FeedbackFilters } from '@/api/feedback';
import FeedbackDetailDialog from '@/components/FeedbackDetailDialog.vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const feedbackStore = useFeedbackStore();
const { feedbackList, total, loading, stats } = storeToRefs(feedbackStore);
// 使用 reactive 创建过滤条件对象
const filters = reactive<Omit<FeedbackFilters, 'page' | 'size'>>({
status: undefined,
pollutionType: undefined,
severityLevel: undefined,
startDate: undefined,
endDate: undefined,
keyword: undefined,
});
// 用于筛选器的状态子集
const filterableStatusMap: Partial<Record<FeedbackStatus, string>> = {
[FeedbackStatus.AI_REVIEWING]: 'AI审核中',
[FeedbackStatus.IN_PROGRESS]: '处理中',
[FeedbackStatus.PENDING_ASSIGNMENT]: '待分配',
[FeedbackStatus.PROCESSED]: '已处理',
};
// 使用计算属性来确保状态的显示名称
const selectedStatusLabel = computed(() => {
return filters.status ? statusMap[filters.status] : '全部状态';
});
const selectedPollutionTypeLabel = computed(() => {
return filters.pollutionType ? pollutionTypeMap[filters.pollutionType] : '所有类型';
});
const selectedSeverityLabel = computed(() => {
return filters.severityLevel ? severityLevelMap[filters.severityLevel] : '所有程度';
});
// 日期范围
const dateRange = ref<[string, string] | null>(null);
const pagination = reactive({
page: 1,
size: 10,
});
const detailDialogVisible = ref(false);
const selectedFeedbackId = ref<number | null>(null);
// 映射关系
const statusMap: Record<string, string> = {
'AI_REVIEWING': 'AI审核中',
'AI_REVIEW_FAILED': 'AI审核失败',
'PENDING_REVIEW': '待人工审核',
'PENDING_ASSIGNMENT': '待分配',
'ASSIGNED': '已分配',
'CONFIRMED': '已确认',
'CLOSED_INVALID': '无效关闭',
'PROCESSED': '已处理',
'IN_PROGRESS': '处理中',
'RESOLVED': '已处理',
'COMPLETED': '已处理'
};
const statusTagMap: Record<string, 'info' | 'warning' | 'danger' | 'success'> = {
'AI_REVIEWING': 'info',
'AI_REVIEW_FAILED': 'danger',
'PENDING_REVIEW': 'warning',
'PENDING_ASSIGNMENT': 'warning',
'ASSIGNED': 'warning',
'CONFIRMED': 'success',
'CLOSED_INVALID': 'info',
'PROCESSED': 'success',
'IN_PROGRESS': 'warning',
'RESOLVED': 'success',
'COMPLETED': 'success'
};
const pollutionTypeMap: Record<PollutionType, string> = {
[PollutionType.PM25]: 'PM2.5',
[PollutionType.O3]: '臭氧',
[PollutionType.SO2]: '二氧化硫',
[PollutionType.NO2]: '二氧化氮',
[PollutionType.OTHER]: '其他'
};
const severityLevelMap: Record<SeverityLevel, string> = {
[SeverityLevel.LOW]: '低',
[SeverityLevel.MEDIUM]: '中',
[SeverityLevel.HIGH]: '高',
};
const severityTagMap: Record<SeverityLevel, 'info' | 'warning' | 'danger'> = {
[SeverityLevel.LOW]: 'info',
[SeverityLevel.MEDIUM]: 'warning',
[SeverityLevel.HIGH]: 'danger',
};
const formatDate = (date: string | null) => {
if (!date) return 'N/A';
return new Date(date).toLocaleString();
};
const fetchFeedbackList = () => {
const params = {
...filters,
page: pagination.page - 1, // 后端分页从0开始
size: pagination.size,
};
feedbackStore.fetchFeedbackList(params);
};
const handleSearch = () => {
pagination.page = 1;
fetchFeedbackList();
};
const handleReset = () => {
Object.assign(filters, {
status: undefined,
pollutionType: undefined,
severityLevel: undefined,
startDate: undefined,
endDate: undefined,
keyword: undefined,
});
dateRange.value = null;
fetchFeedbackList();
};
const handleSizeChange = (size: number) => {
pagination.size = size;
fetchFeedbackList();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
fetchFeedbackList();
};
const handleViewDetails = (row: FeedbackResponse) => {
selectedFeedbackId.value = row.id;
detailDialogVisible.value = true;
};
const fetchFeedbackStats = () => {
feedbackStore.fetchFeedbackStats();
};
const filterOptions = {
status: [
{ value: 'PENDING_REVIEW', label: '待人工审核' },
{ value: 'PENDING_ASSIGNMENT', label: '待分配' },
{ value: 'ASSIGNED', label: '已分配' },
{ value: 'IN_PROGRESS', label: '处理中' },
{ value: 'PROCESSED', label: '已处理' },
{ value: 'CLOSED_INVALID', label: '无效关闭' }
],
pollutionType: Object.entries(pollutionTypeMap).map(([value, label]) => ({ value, label })),
// ... more filter options
};
onMounted(() => {
fetchFeedbackList();
fetchFeedbackStats();
});
</script>
<style scoped>
.feedback-management-page {
padding: 20px;
}
.stats-row {
margin-bottom: 20px;
}
.stats-card {
border-radius: 8px;
transition: all 0.3s;
cursor: pointer;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
}
.stats-item {
text-align: center;
padding: 10px;
}
.stats-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.stats-label {
font-size: 14px;
color: #606266;
}
.pending .stats-value {
color: #e6a23c;
}
.confirmed .stats-value {
color: #409eff;
}
.assigned .stats-value {
color: #67c23a;
}
.resolved .stats-value {
color: #67c23a;
}
.rejected .stats-value {
color: #f56c6c;
}
.page-card {
border-radius: 8px;
}
.card-header {
font-size: 18px;
font-weight: 600;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form .el-form-item {
margin-bottom: 10px;
}
.feedback-table {
width: 100%;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 选中的过滤器样式 */
:deep(.el-select) .el-input__prefix {
font-weight: bold;
color: #409eff !important;
}
:deep(.el-input--prefix .el-input__inner) {
color: #409eff;
font-weight: bold;
}
:deep(.el-date-editor.is-active) {
color: #409eff;
font-weight: bold;
}
:deep(.el-input.is-active .el-input__inner) {
color: #409eff;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="forgot-password-container">
<el-card class="forgot-password-card">
<div class="card-header">
<h2>找回密码</h2>
<p>请输入您的注册邮箱以接收密码重置邮件</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
hide-required-asterisk
@submit.prevent="handleSubmit"
autocomplete="off"
>
<el-form-item label="邮箱地址" prop="email">
<el-input
v-model="form.email"
placeholder="请输入您注册时使用的邮箱"
size="large"
autocomplete="off"
>
<template #append>
<el-button
:disabled="isSending"
@click="handleSubmit"
>
{{ buttonText }}
</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="card-footer">
<el-link @click="$router.push('/login')">返回登录</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { sendPasswordResetCode } from '@/api/auth';
const router = useRouter();
const formRef = ref<FormInstance>();
const isSending = ref(false);
const countdown = ref(0);
const form = reactive({
email: '',
});
const rules = reactive({
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
],
});
const buttonText = computed(() => {
if (isSending.value) {
return `${countdown.value}s 后可重发`;
}
return '发送重置邮件';
});
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (valid) {
isSending.value = true;
countdown.value = 60;
try {
await sendPasswordResetCode(form.email);
ElMessage.success('密码重置邮件已发送,即将跳转...');
const timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
clearInterval(timer);
isSending.value = false;
}
}, 1000);
setTimeout(() => {
router.push({ path: '/reset-password', query: { email: form.email } });
}, 2000);
} catch (error) {
ElMessage.error('邮件发送失败,请检查邮箱地址是否正确。');
isSending.value = false;
countdown.value = 0;
}
}
});
};
</script>
<style scoped>
.forgot-password-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80');
background-size: cover;
background-position: center;
}
.forgot-password-card {
width: 480px;
border-radius: 25px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
padding: 20px;
}
.card-header {
text-align: center;
margin-bottom: 2rem;
}
.card-header h2 {
color: #004d40;
margin-bottom: 0.5rem;
font-weight: 600;
}
.card-header p {
color: #607d8b;
font-size: 0.9rem;
}
.card-footer {
margin-top: 1.5rem;
text-align: center;
}
.submit-button {
width: 100%;
border-radius: 10px;
background-color: #004d40;
border-color: #004d40;
transition: background-color 0.3s ease;
font-size: 1rem;
padding: 1.2rem 0;
}
.submit-button:hover {
background-color: #00695c;
border-color: #00695c;
}
:deep(.el-input__wrapper) {
border-top-left-radius: 15px;
border-bottom-left-radius: 15px;
}
:deep(.el-input-group__append) {
background-color: #004d40;
color: white;
border: none;
cursor: pointer;
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
}
:deep(.el-input-group__append:hover) {
background-color: #00695c;
}
:deep(.el-input-group__append .el-button) {
background: transparent;
color: white;
border: none;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,731 @@
<template>
<div class="grid-management-container">
<!-- Page Header -->
<div class="page-header">
<h2>网格地图</h2>
<div class="header-actions">
<el-button type="primary" @click="refreshData" :loading="loading || isLoadingWorkers" icon="Refresh">
刷新数据
</el-button>
</div>
</div>
<!-- Main Content -->
<div class="main-content-grid">
<!-- Grid Map Area -->
<div class="map-area" v-loading="loading">
<div v-if="error" class="error-message">
<el-alert title="地图数据加载失败" :description="error" type="error" show-icon :closable="false" />
<el-button @click="fetchAllGrids" type="primary" class="retry-button">重试</el-button>
</div>
<svg v-else-if="displayGrids.length" :width="svgDimensions.width" :height="svgDimensions.height" class="grid-svg">
<defs>
<pattern id="grid-pattern" :width="CELL_SIZE" :height="CELL_SIZE" patternUnits="userSpaceOnUse">
<path :d="`M ${CELL_SIZE} 0 L 0 0 0 ${CELL_SIZE}`" fill="none" stroke="#e9e9e9" stroke-width="0.5" />
</pattern>
<!-- Softer Gradient for the path -->
<linearGradient id="soft-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#89f7fe; stop-opacity:1" /> <!-- Light Sky Blue -->
<stop offset="100%" style="stop-color:#66a6ff; stop-opacity:1" /> <!-- Soft Lavender Blue -->
</linearGradient>
</defs>
<rect :width="svgDimensions.width" :height="svgDimensions.height" fill="url(#grid-pattern)" />
<g v-for="grid in displayGrids" :key="grid.id">
<!-- Replace rect with foreignObject to allow for CSS pseudo-elements -->
<foreignObject
:x="grid.displayX * CELL_SIZE"
:y="grid.displayY * CELL_SIZE"
:width="CELL_SIZE"
:height="CELL_SIZE"
>
<div
xmlns="http://www.w3.org/1999/xhtml"
class="grid-cell-wrapper"
@click="selectGrid(grid)"
>
<div
:class="{
'grid-cell': true,
'selected': selectedGrid && selectedGrid.id === grid.id,
'is-path': pathInfo.pathSet.has(`${grid.gridX},${grid.gridY}`),
'is-start': pathInfo.start === `${grid.gridX},${grid.gridY}`,
'is-end': pathInfo.end === `${grid.gridX},${grid.gridY}`
}"
:style="{
'animation-delay': `${(pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) * 50}ms`,
'background-color': getGridFillColor(grid)
}"
></div>
</div>
</foreignObject>
<!-- Path Number Text remains as SVG text -->
<text
v-if="pathInfo.pathSet.has(`${grid.gridX},${grid.gridY}`)"
:x="grid.displayX * CELL_SIZE + CELL_SIZE / 2"
:y="grid.displayY * CELL_SIZE + CELL_SIZE / 2"
class="path-text"
:style="{ 'animation-delay': `${(pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) * 50}ms` }"
>
{{ (pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) + 1 }}
</text>
<!-- Border Rendering -->
<line v-if="getBorder(grid, 'top')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="grid.displayY * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'right')" :x1="(grid.displayX + 1) * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'bottom')" :x1="grid.displayX * CELL_SIZE" :y1="(grid.displayY + 1) * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'left')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="grid.displayX * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
</g>
</svg>
<el-empty v-else description="没有可显示的网格数据" />
</div>
<!-- Details Sidebar -->
<div class="sidebar-area">
<el-card shadow="never" class="details-card">
<template #header>
<div class="card-header">
<span>网格详情</span>
<el-button v-if="selectedGrid && !isEditing && canEdit" type="primary" link @click="enterEditMode" icon="Edit">
编辑
</el-button>
</div>
</template>
<div v-if="isSaving" v-loading="true" class="details-content-loading"></div>
<div v-else-if="selectedGrid" class="details-content">
<!-- Display Mode -->
<template v-if="!isEditing">
<p><strong>网格ID:</strong> {{ selectedGrid.id }}</p>
<p><strong>城市:</strong> <el-tag :color="getCityColor(selectedGrid.cityName)" effect="light">{{ selectedGrid.cityName }}</el-tag></p>
<p><strong>区县:</strong> {{ selectedGrid.districtName || 'N/A' }}</p>
<p><strong>原始坐标:</strong> ({{ selectedGrid.gridX }}, {{ selectedGrid.gridY }})</p>
<p><strong>是否为障碍物:</strong> <el-tag :type="selectedGrid.isObstacle ? 'danger' : 'success'">{{ selectedGrid.isObstacle ? '是' : '否' }}</el-tag></p>
<p><strong>描述:</strong> {{ selectedGrid.description || '无' }}</p>
<el-divider />
<p><strong>负责人:</strong></p>
<div v-if="selectedGridWorker">
<el-tag effect="plain" type="info">
<el-icon><User /></el-icon>
{{ selectedGridWorker.name }} ({{ selectedGridWorker.phone }})
</el-tag>
</div>
<div v-else>
<el-tag type="warning">未分配</el-tag>
</div>
</template>
<!-- Editing Mode -->
<template v-else-if="canEdit">
<el-form label-position="top" label-width="100px">
<el-form-item label="是否为障碍物">
<el-switch v-model="editableIsObstacle" />
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" v-model="editableDescription" :rows="3" />
</el-form-item>
<el-form-item label="分配网格员">
<el-select
v-model="selectedWorkerIdToAssign"
placeholder="请选择网格员"
clearable
filterable
style="width: 100%;"
>
<el-option label="-- 未分配 --" value="unassigned"></el-option>
<el-option
v-for="worker in allWorkers"
:key="worker.id"
:label="`${worker.name} (${worker.phone})`"
:value="worker.id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="edit-actions">
<el-button @click="cancelEdit">取消</el-button>
<el-button type="primary" @click="saveChanges" :loading="isSaving">保存</el-button>
</div>
</template>
</div>
<el-empty v-else description="请在左侧地图上选择一个网格" />
</el-card>
<!-- Pathfinding Card -->
<el-card shadow="never" class="details-card pathfinding-card">
<template #header>
<div class="card-header">
<span>路径规划</span>
</div>
</template>
<div v-if="!authStore.user" class="pathfinding-content">
<el-empty description="请先登录以使用路径规划功能" />
</div>
<div v-else class="pathfinding-content">
<el-form label-position="top" :model="pathfinding" @submit.prevent="handleFindPath">
<el-form-item label="起点坐标 (X, Y)">
<div style="display: flex; gap: 8px; width: 100%;">
<el-input-number v-model="pathfinding.startX" :controls="false" placeholder="X" style="width: 100%" />
<el-input-number v-model="pathfinding.startY" :controls="false" placeholder="Y" style="width: 100%" />
</div>
<small class="form-help-text">默认为您分配的网格 (0,0)</small>
</el-form-item>
<el-form-item label="终点坐标 (X, Y)">
<div style="display: flex; gap: 8px; width: 100%;">
<el-input-number v-model="pathfinding.endX" :controls="false" placeholder="X" style="width: 100%" />
<el-input-number v-model="pathfinding.endY" :controls="false" placeholder="Y" style="width: 100%" />
</div>
<small class="form-help-text">点击地图上的网格可快速设置终点</small>
</el-form-item>
</el-form>
<div class="pathfinding-actions">
<el-button @click="clearPath" :disabled="!calculatedPath.length">清除路径</el-button>
<el-button type="primary" @click="handleFindPath" :loading="isPathfindingLoading">规划路径</el-button>
</div>
</div>
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import type { Grid, UserAccount } from '@/api/types';
import { getGrids } from '@/api/dashboard';
import { getAllGridWorkers } from '@/api/personnel';
import { updateGrid, assignWorkerByCoordinates, unassignWorkerByCoordinates } from '@/api/grid';
import { findPath, type Point, type PathfindingRequest } from '@/api/pathfinding';
import { User } from '@element-plus/icons-vue';
const CELL_SIZE = 25;
const PADDING_CELLS = 2;
const authStore = useAuthStore();
// --- State ---
const gridData = ref<Grid[]>([]);
const allWorkers = ref<UserAccount[]>([]);
const loading = ref(false); // For main grid loading
const isLoadingWorkers = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const selectedGrid = ref<DisplayGrid | null>(null);
const isEditing = ref(false);
// --- Editing State ---
const editableDescription = ref('');
const editableIsObstacle = ref(false);
const selectedWorkerIdToAssign = ref<number | 'unassigned' | null>(null);
// --- Pathfinding State ---
const pathfinding = ref({
startX: 0,
startY: 0,
endX: undefined as number | undefined,
endY: undefined as number | undefined
});
const calculatedPath = ref<Point[]>([]);
const isPathfindingLoading = ref(false);
// --- Data Fetching & Refresh ---
const fetchAllGrids = async () => {
loading.value = true;
error.value = null;
selectedGrid.value = null; // Reset selection
try {
const response = await getGrids();
gridData.value = Array.isArray(response) ? response : (response as any).content || [];
} catch (e: any) {
const errorMessage = e.message || '获取网格数据时发生未知错误';
error.value = errorMessage;
ElMessage.error(errorMessage);
} finally {
loading.value = false;
}
};
const fetchAllWorkers = async () => {
const userRole = authStore.user?.role;
// 只有管理员和主管需要获取完整的网格员列表用于管理
if (userRole !== 'ADMIN' && userRole !== 'SUPERVISOR') {
allWorkers.value = []; // 其他角色无需加载网格员数据
return;
}
isLoadingWorkers.value = true;
try {
allWorkers.value = await getAllGridWorkers();
} catch (e: any) {
ElMessage.error('获取网格员列表失败: ' + e.message);
} finally {
isLoadingWorkers.value = false;
}
};
const refreshData = async () => {
await Promise.all([fetchAllGrids(), fetchAllWorkers()]);
ElMessage.success('数据已刷新');
// Set default start point for pathfinding if user has one
const user = authStore.user;
if (user && typeof user.gridX === 'number' && typeof user.gridY === 'number') {
pathfinding.value.startX = user.gridX;
pathfinding.value.startY = user.gridY;
}
};
onMounted(() => {
refreshData();
});
// --- Computed Properties ---
const canEdit = computed(() => {
const role = authStore.user?.role;
return role === 'ADMIN' || role === 'SUPERVISOR';
});
const workersByCoord = computed<Map<string, UserAccount>>(() => {
const map = new Map<string, UserAccount>();
allWorkers.value.forEach(worker => {
if (worker.gridX !== null && worker.gridY !== null) {
map.set(`${worker.gridX},${worker.gridY}`, worker);
}
});
return map;
});
const selectedGridWorker = computed<UserAccount | undefined>(() => {
if (!selectedGrid.value || !allWorkers.value.length) {
return undefined;
}
return allWorkers.value.find(
worker => worker.gridX === selectedGrid.value?.gridX && worker.gridY === selectedGrid.value?.gridY
);
});
const pathInfo = computed(() => {
const pathSet = new Set<string>();
const pathMap = new Map<string, number>(); // Stores coordinate -> index for animation delay
let start = '';
let end = '';
if (calculatedPath.value.length > 0) {
calculatedPath.value.forEach((point, index) => {
const coord = `${point.x},${point.y}`;
pathSet.add(coord);
pathMap.set(coord, index);
});
const startPoint = calculatedPath.value[0];
const endPoint = calculatedPath.value[calculatedPath.value.length - 1];
start = `${startPoint.x},${startPoint.y}`;
end = `${endPoint.x},${endPoint.y}`;
}
return { pathSet, pathMap, start, end };
});
// --- City Color Logic ---
const cityColors = ref<Map<string, string>>(new Map());
const getCityColor = (cityName: string): string => {
if (!cityColors.value.has(cityName)) {
let hash = 0;
for (let i = 0; i < cityName.length; i++) {
hash = cityName.charCodeAt(i) + ((hash << 5) - hash);
}
const color = `hsl(${hash % 360}, 80%, 85%)`;
cityColors.value.set(cityName, color);
}
return cityColors.value.get(cityName)!;
};
const getGridFillColor = (grid: Grid): string => {
const isPath = pathInfo.value.pathSet.has(`${grid.gridX},${grid.gridY}`);
// If it's part of the path, let the CSS class handle the gradient background
if (isPath) {
return 'transparent';
}
if (grid.isObstacle) {
return '#000000'; // Black for obstacles
}
if (workersByCoord.value.has(`${grid.gridX},${grid.gridY}`)) {
return '#FF0000'; // Red for grids with workers
}
return getCityColor(grid.cityName);
};
// --- Display Logic ---
interface DisplayGrid extends Grid {
displayX: number;
displayY: number;
}
const displayGrids = computed<DisplayGrid[]>(() => {
if (!gridData.value.length) return [];
// Determine the top-left corner of the entire map to normalize coordinates
const minX = Math.min(...gridData.value.map(g => g.gridX));
const minY = Math.min(...gridData.value.map(g => g.gridY));
// Render all grids based on their absolute positions, normalized to start at (0,0)
return gridData.value.map(grid => ({
...grid,
displayX: grid.gridX - minX,
displayY: grid.gridY - minY,
}));
});
const gridMap = computed(() => {
const map = new Map<string, DisplayGrid>();
displayGrids.value.forEach(grid => {
map.set(`${grid.displayX},${grid.displayY}`, grid);
});
return map;
});
const svgDimensions = computed(() => {
if (!displayGrids.value.length) return { width: 0, height: 0 };
const maxX = Math.max(...displayGrids.value.map(g => g.displayX));
const maxY = Math.max(...displayGrids.value.map(g => g.displayY));
return {
width: (maxX + 1) * CELL_SIZE,
height: (maxY + 1) * CELL_SIZE,
};
});
const getBorder = (grid: DisplayGrid, side: 'top' | 'right' | 'bottom' | 'left'): boolean => {
const { displayX, displayY, cityName } = grid;
let neighbor: DisplayGrid | undefined;
switch (side) {
case 'top':
neighbor = gridMap.value.get(`${displayX},${displayY - 1}`);
break;
case 'right':
neighbor = gridMap.value.get(`${displayX + 1},${displayY}`);
break;
case 'bottom':
neighbor = gridMap.value.get(`${displayX},${displayY + 1}`);
break;
case 'left':
neighbor = gridMap.value.get(`${displayX - 1},${displayY}`);
break;
}
return !neighbor || neighbor.cityName !== cityName;
};
const selectGrid = (grid: DisplayGrid) => {
selectedGrid.value = grid;
// Also set pathfinding endpoint for convenience
pathfinding.value.endX = grid.gridX;
pathfinding.value.endY = grid.gridY;
// If in edit mode, populate fields
if (isEditing.value) {
// populateEditFields(grid); // This function was removed, keep it commented or remove it if not needed
}
};
// --- Editing Logic ---
const enterEditMode = () => {
if (!selectedGrid.value) return;
isEditing.value = true;
editableDescription.value = selectedGrid.value.description || '';
editableIsObstacle.value = selectedGrid.value.isObstacle;
selectedWorkerIdToAssign.value = selectedGridWorker.value?.id ?? 'unassigned';
};
const cancelEdit = () => {
isEditing.value = false;
};
const saveChanges = async () => {
if (!selectedGrid.value) return;
isSaving.value = true;
try {
const { id, gridX, gridY } = selectedGrid.value;
let needsGridRefresh = false;
let needsWorkerRefresh = false;
// 1. Update grid details (obstacle, description)
const originalDescription = selectedGrid.value.description || '';
if (editableIsObstacle.value !== selectedGrid.value.isObstacle || editableDescription.value !== originalDescription) {
await updateGrid(id, {
isObstacle: editableIsObstacle.value,
description: editableDescription.value,
});
needsGridRefresh = true;
}
// 2. Update worker assignment
const originalWorkerId = selectedGridWorker.value?.id;
const newWorkerId = selectedWorkerIdToAssign.value;
if (newWorkerId !== (originalWorkerId ?? 'unassigned')) {
if (newWorkerId === 'unassigned') {
await unassignWorkerByCoordinates(gridX, gridY);
} else if (newWorkerId) {
await assignWorkerByCoordinates(gridX, gridY, newWorkerId);
}
needsWorkerRefresh = true;
}
ElMessage.success('网格信息更新成功!');
// Refresh data
if (needsGridRefresh) await fetchAllGrids();
if (needsWorkerRefresh) await fetchAllWorkers();
isEditing.value = false;
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.message || e.message));
} finally {
isSaving.value = false;
}
};
// --- Pathfinding Methods ---
const handleFindPath = async () => {
if (typeof pathfinding.value.endX !== 'number' || typeof pathfinding.value.endY !== 'number') {
ElMessage.warning('请输入有效的终点坐标。');
return;
}
isPathfindingLoading.value = true;
calculatedPath.value = []; // Clear previous path
const request: PathfindingRequest = {
startX: pathfinding.value.startX,
startY: pathfinding.value.startY,
endX: pathfinding.value.endX,
endY: pathfinding.value.endY,
};
try {
const path = await findPath(request);
if (path && path.length > 0) {
calculatedPath.value = path;
ElMessage.success(`路径规划成功,共 ${path.length} 步。`);
} else {
ElMessage.error('未找到有效路径,请检查起终点或地图障碍物。');
}
} catch (e: any) {
ElMessage.error('路径规划失败: ' + (e.response?.data?.message || e.message));
} finally {
isPathfindingLoading.value = false;
}
};
const clearPath = () => {
calculatedPath.value = [];
ElMessage.info('路径已清除。');
};
</script>
<style scoped>
.grid-management-container {
padding: 20px;
background-color: #f7f8fa;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-shrink: 0;
}
.page-header h2 {
margin: 0;
font-size: 24px;
}
.main-content-grid {
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
}
.map-area {
flex: 3;
background-color: #fff;
border-radius: 8px;
padding: 10px;
overflow: auto;
border: 1px solid #e9e9e9;
}
.sidebar-area {
flex: 1;
min-width: 300px;
}
.details-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.grid-svg {
display: block;
}
.grid-cell-wrapper {
width: 100%;
height: 100%;
cursor: pointer;
}
.grid-cell {
width: 100%;
height: 100%;
background-color: transparent; /* Default state */
border: 0.5px solid #dcdfe6;
box-sizing: border-box;
transition: all 0.2s ease;
position: relative;
overflow: hidden; /* Important for pseudo-element animation */
}
.grid-cell:hover {
filter: brightness(0.9);
}
.grid-cell.selected {
border: 2px solid #ff4d4f;
}
.grid-cell.is-start {
background-color: #00e676;
box-shadow: 0 0 8px #00e676, inset 0 0 5px rgba(255,255,255,0.7);
border-color: #fff;
}
.grid-cell.is-end {
background-color: #ff5252;
box-shadow: 0 0 8px #ff5252, inset 0 0 5px rgba(255,255,255,0.7);
border-color: #fff;
}
.grid-cell.is-path {
background-image: linear-gradient(to bottom right, #89f7fe, #66a6ff);
border-color: #66a6ff;
opacity: 0;
animation: draw-path-reveal 0.4s forwards ease-out;
}
.grid-cell.is-path::before {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 50%;
height: 100%;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0) 100%);
transform: skewX(-25deg);
animation: light-sweep 1.2s ease-in-out 3 forwards; /* Play 3 times and stop */
animation-delay: inherit; /* Inherit delay from parent */
}
@keyframes light-sweep {
100% {
left: 150%;
}
}
.path-text {
font-size: 10px;
fill: white;
font-weight: bold;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
animation: draw-path-reveal 0.4s forwards ease-out;
}
.border-line {
stroke: #000;
stroke-width: 2.5px;
margin-top: 15px;
width: 100%; /* Ensure it takes full width */
}
.error-message {
padding: 20px;
}
.retry-button {
margin-top: 15px;
}
.details-content p {
margin: 0 0 12px;
color: #606266;
margin-top: 4px;
line-height: 1.2;
width: 100%; /* Ensure it takes full width */
}
.details-content p strong {
color: #303133;
margin-right: 8px;
}
.details-content-loading {
height: 200px;
}
.edit-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.pathfinding-card {
margin-top: 20px;
}
.pathfinding-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-help-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.2;
width: 100%; /* Ensure it takes full width */
}
@keyframes draw-path-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
filter: brightness(1);
transform: scale(1);
}
50% {
filter: brightness(1.7);
transform: scale(1.05);
}
}
</style>

View File

@@ -0,0 +1,1791 @@
<template>
<div v-if="canViewDashboard" class="dashboard-container">
<div class="dashboard-header">
<h1>环境监测系统仪表盘</h1>
<div class="dashboard-actions">
<el-button type="primary" :icon="Refresh" @click="refreshAllData">刷新全部</el-button>
<el-button type="success" :icon="Setting">设置</el-button>
</div>
</div>
<div class="dashboard-grid">
<!-- Top Row: Key Stats -->
<el-card shadow="hover" class="grid-item kpi-card" v-for="kpi in kpiStats" :key="kpi.title">
<div class="kpi-content">
<div class="kpi-icon" :style="{ backgroundColor: kpi.color }">
<component :is="kpi.icon" />
</div>
<div class="kpi-details">
<div class="kpi-title">{{ kpi.title }}</div>
<div class="kpi-value">
<span v-if="!loading.stats">{{ kpi.value }}</span>
<span v-else class="loading-text">--</span>
</div>
</div>
</div>
</el-card>
<!-- AQI Distribution Chart -->
<el-card shadow="never" class="grid-item chart-card aqi-distribution" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataAnalysis /></el-icon>
<span>AQI 级别分布</span>
<div class="card-actions">
<el-tooltip content="查看详细数据" placement="top">
<el-button type="success" :icon="View" circle size="small" @click="showAqiDetailData" />
</el-tooltip>
<el-tooltip content="刷新数据" placement="top">
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshAqiData" :loading="loading.aqi" />
</el-tooltip>
</div>
</div>
</template>
<div ref="aqiDistributionChartRef" class="chart" v-if="!loading.aqi" />
<el-skeleton :rows="5" animated v-else />
</el-card>
<!-- Task Status Chart -->
<el-card shadow="never" class="grid-item chart-card task-status" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Finished /></el-icon>
<span>任务状态分布</span>
<div class="card-actions">
<el-tooltip content="查看详细数据" placement="top">
<el-button type="success" :icon="View" circle size="small" @click="showTaskDetailData" />
</el-tooltip>
<el-tooltip content="刷新数据" placement="top">
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshTaskData" :loading="loading.tasks" />
</el-tooltip>
</div>
</div>
</template>
<div ref="taskStatusChartRef" class="chart" v-if="!loading.tasks" />
<el-skeleton :rows="5" animated v-else />
</el-card>
<!-- Exceedance Trend Chart -->
<el-card shadow="never" class="grid-item chart-card exceedance-trend" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><TrendCharts /></el-icon>
<span>月度超标趋势</span>
<div class="card-actions">
<el-tooltip content="查看详细数据" placement="top">
<el-button type="success" :icon="View" circle size="small" @click="showTrendDetailData" />
</el-tooltip>
<el-tooltip content="刷新数据" placement="top">
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshTrendData" :loading="loading.trend" />
</el-tooltip>
</div>
</div>
</template>
<div ref="exceedanceTrendChartRef" class="chart" v-if="!loading.trend" />
<el-skeleton :rows="8" animated v-else />
</el-card>
<!-- Pollutant Trend Chart -->
<el-card shadow="never" class="grid-item chart-card pollutant-trend" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span>污染物趋势</span>
<div class="card-actions">
<el-select v-model="selectedPollutantType" placeholder="选择污染物" size="small" style="width: 120px; margin-right: 10px;" @change="handlePollutantTypeChange">
<el-option v-for="item in pollutantTypes" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-tooltip content="查看详细数据" placement="top">
<el-button type="success" :icon="View" circle size="small" @click="showPollutantDetailData" />
</el-tooltip>
<el-tooltip content="刷新数据" placement="top">
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshPollutantTrendData" :loading="loading.pollutantTrend" />
</el-tooltip>
</div>
</div>
</template>
<div ref="pollutantTrendChartRef" class="chart" v-if="!loading.pollutantTrend && !errors.pollutantTrend" />
<el-skeleton :rows="8" animated v-else-if="loading.pollutantTrend" />
<el-result v-else icon="error" title="数据加载失败" sub-title="无法加载污染物趋势数据请稍后重试">
<template #extra>
<el-button type="primary" @click="refreshPollutantTrendData">重试</el-button>
</template>
</el-result>
</el-card>
<!-- Grid Coverage Table -->
<el-card
shadow="never"
class="grid-item table-card grid-coverage"
body-style="padding: 16px; height: calc(100% - 53px); box-sizing: border-box;"
header-class="coverage-header"
body-class="coverage-body"
footer-class=""
>
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Grid /></el-icon>
<span>网格覆盖率 (按城市)</span>
<div class="card-actions">
<el-tooltip content="刷新数据" placement="top">
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshCoverageData" :loading="loading.coverage" />
</el-tooltip>
</div>
</div>
</template>
<el-table
:data="gridCoverageData"
style="width: 100%"
height="100%"
v-if="!loading.coverage"
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
:row-class-name="coverageTableRowClassName"
border
>
<el-table-column prop="city" label="城市" />
<el-table-column prop="coverageRate" label="覆盖率" width="220">
<template #default="{ row }">
<el-progress
:percentage="row.coverageRate"
:color="getCoverageColor(row.coverageRate)"
:stroke-width="18"
:show-text="true"
:format="percentageFormat"
/>
</template>
</el-table-column>
<el-table-column prop="coveredGrids" label="已覆盖" align="center" />
<el-table-column prop="totalGrids" label="总网格" align="center" />
</el-table>
<el-skeleton :rows="5" animated v-else />
</el-card>
</div>
<div class="dashboard-footer">
<p>© 2025 环境监测系统 - 实时数据更新于 {{ currentTime }}</p>
</div>
<!-- 详细数据对话框 -->
<el-dialog
v-model="detailDialogVisible"
:title="detailDialogTitle"
width="80%"
:before-close="closeDetailDialog"
destroy-on-close
class="detail-dialog"
>
<div class="detail-table-container">
<el-table
:data="detailTableData"
border
style="width: 100%"
:header-cell-style="{
background: 'linear-gradient(to right, #409EFF, #67C23A)',
color: '#fff',
fontWeight: 'bold',
fontSize: '16px',
padding: '12px 8px',
textAlign: 'center'
}"
:cell-style="{
textAlign: 'center',
padding: '12px 8px',
fontSize: '14px'
}"
:row-class-name="tableRowClassName"
highlight-current-row
>
<el-table-column
v-for="column in detailTableColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:align="column.align || 'center'"
/>
</el-table>
</div>
<template #footer>
<div class="detail-dialog-footer">
<el-button @click="closeDetailDialog" plain>关闭</el-button>
<el-button type="primary" @click="exportDetailData" :icon="Download">导出数据</el-button>
</div>
</template>
</el-dialog>
</div>
<div v-else class="welcome-container">
<el-result
icon="success"
title="欢迎回来"
:sub-title="`您好, ${user?.name || '用户'}您当前的角色是 ${user?.role || '未知'}没有权限访问仪表盘`"
>
<template #extra>
<el-button type="primary" @click="goBack">返回上一页</el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick, onBeforeUnmount, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart, LineChart } from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
} from 'echarts/components';
import { ElMessage } from 'element-plus';
import { getDashboardStats, getAqiDistribution, getTaskCompletionStats, getMonthlyExceedanceTrend, getGridCoverageByCity, getPollutantMonthlyTrends } from '@/api/dashboard';
import type { DashboardStatsDTO, AqiDistributionDTO, TaskStatsDTO, TrendDataPointDTO, GridCoverageDTO } from '@/api/types';
import { Briefcase, CircleCheck, WarnTriangleFilled, Grid, DataAnalysis, Finished, TrendCharts, Refresh, Setting, DataLine, View, Download } from '@element-plus/icons-vue';
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const canViewDashboard = computed(() => {
if (!user.value) return false;
return ['ADMIN', 'DECISION_MAKER'].includes(user.value.role);
});
const router = useRouter();
echarts.use([
CanvasRenderer,
PieChart,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
]);
// --- Chart Element Refs ---
const aqiDistributionChartRef = ref<HTMLElement | null>(null);
const taskStatusChartRef = ref<HTMLElement | null>(null);
const exceedanceTrendChartRef = ref<HTMLElement | null>(null);
const pollutantTrendChartRef = ref<HTMLElement | null>(null);
// --- Timer Ref ---
const timer = ref<number | null>(null);
// --- Loading & Data State ---
const loading = reactive({
stats: false,
aqi: false,
tasks: false,
trend: false,
coverage: false,
pollutantTrend: false,
});
const errors = reactive({
stats: false,
aqi: false,
tasks: false,
trend: false,
coverage: false,
pollutantTrend: false,
});
const statsData = ref<Partial<DashboardStatsDTO & TaskStatsDTO>>({});
const aqiDistributionData = ref<AqiDistributionDTO[]>([]);
const taskStatusData = ref<TaskStatsDTO>({ totalTasks: 0, completedTasks: 0, completionRate: 0 });
const exceedanceTrendData = ref<TrendDataPointDTO[]>([]);
const gridCoverageData = ref<GridCoverageDTO[]>([]);
const pollutantTrendData = ref<Record<string, TrendDataPointDTO[]>>({});
const currentTime = ref(new Date().toLocaleString());
// 污染物类型选择
const selectedPollutantType = ref<string>('ALL');
const pollutantTypes = [
{ label: '全部污染物', value: 'ALL' },
{ label: 'PM2.5', value: 'PM25' },
{ label: '臭氧(O₃)', value: 'O3' },
{ label: '二氧化氮(NO₂)', value: 'NO2' },
{ label: '二氧化硫(SO₂)', value: 'SO2' },
{ label: '其他污染物', value: 'OTHER' }
];
// 处理污染物类型变化
const handlePollutantTypeChange = () => {
console.log('选择的污染物类型:', selectedPollutantType.value);
// 先销毁旧的图表实例
if (pollutantTrendChartRef.value) {
const oldChart = echarts.getInstanceByDom(pollutantTrendChartRef.value);
if (oldChart) {
oldChart.dispose();
}
}
// 然后重新初始化图表
nextTick(() => {
initPollutantTrendChart();
});
};
// --- Timer for current time ---
const timeInterval = setInterval(() => {
currentTime.value = new Date().toLocaleString();
}, 1000);
// --- KPI Stats ---
const kpiStats = computed(() => [
{
title: '待办任务总数',
value: statsData.value?.totalTasks ?? 0,
icon: Briefcase,
color: '#409EFF',
},
{
title: '待处理任务',
value: (statsData.value?.totalTasks ?? 0) - (statsData.value?.completedTasks ?? 0),
icon: CircleCheck,
color: '#67C23A',
},
{
title: '待人工审核反馈',
value: statsData.value?.totalFeedbacks ?? 0,
icon: WarnTriangleFilled,
color: '#E6A23C',
},
{
title: '活动用户数',
value: statsData.value?.activeGridWorkers ?? 0,
icon: Grid,
color: '#F56C6C',
}
]);
// --- Chart Logic ---
const getAqiColor = (level: string): string => {
const colorMap: { [key: string]: string } = {
'Good': '#67c23a',
'Moderate': '#f5c85b',
'Unhealthy for Sensitive Groups': '#f5a623',
'Unhealthy': '#d0021b',
'Very Unhealthy': '#8b0000',
'Hazardous': '#4a0000',
};
return colorMap[level] || '#999';
};
const initAqiChart = () => {
if (aqiDistributionChartRef.value && aqiDistributionData.value.length > 0) {
const chart = echarts.init(aqiDistributionChartRef.value);
chart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
type: 'scroll',
top: '85%',
left: 'center',
textStyle: { fontWeight: 'bold' },
formatter: function(name: string) {
// 如果名称太长,截断并添加省略号
return name.length > 10 ? name.slice(0, 10) + '...' : name;
}
},
series: [{
name: 'AQI 分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '40%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
label: {
show: true,
position: 'outside',
formatter: '{d}%',
overflow: 'truncate',
width: 80
},
labelLine: {
length: 15,
length2: 10,
smooth: true
},
emphasis: {
label: { show: true, fontSize: '18', fontWeight: 'bold' },
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
},
data: aqiDistributionData.value.map(item => ({
value: item.count,
name: item.level,
itemStyle: { color: getAqiColor(item.level) }
})),
}]
});
// 响应式调整
window.addEventListener('resize', () => chart.resize());
}
};
const initTaskChart = () => {
if (taskStatusChartRef.value && taskStatusData.value.totalTasks > 0) {
const chart = echarts.init(taskStatusChartRef.value);
const notCompleted = taskStatusData.value.totalTasks - taskStatusData.value.completedTasks;
chart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
top: '85%',
left: 'center',
textStyle: { fontWeight: 'bold' }
},
series: [{
name: '任务状态',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '40%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'inside',
formatter: '{d}%',
fontSize: 16,
fontWeight: 'bold'
},
emphasis: {
label: { show: true, fontSize: '20', fontWeight: 'bold' },
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
},
data: [
{
value: taskStatusData.value.completedTasks,
name: '已完成',
itemStyle: {
color: '#67c23a',
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
}
},
{
value: notCompleted,
name: '未完成',
itemStyle: {
color: '#f56c6c',
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
}
},
],
}]
});
// 响应式调整
window.addEventListener('resize', () => chart.resize());
}
};
const initTrendChart = () => {
if (exceedanceTrendChartRef.value && exceedanceTrendData.value.length > 0) {
const chart = echarts.init(exceedanceTrendChartRef.value);
// 确保数据格式正确
console.log("趋势数据详情:", JSON.stringify(exceedanceTrendData.value));
// 准备数据
const xAxisData = exceedanceTrendData.value.map(p => p.yearMonth || '未知');
const seriesData = exceedanceTrendData.value.map(p => p.count || 0);
console.log("X轴数据:", xAxisData);
console.log("系列数据:", seriesData);
const option = {
title: {
text: '过去12个月污染超标情况',
left: 'center',
top: 0,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#303133'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
shadowStyle: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
formatter: function(params: any) {
const data = params[0];
return `${data.name}<br/>${data.seriesName}: <strong>${data.value}</strong> 次`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xAxisData,
axisLabel: {
interval: 'auto',
rotate: 45,
formatter: function(value: string) {
if (!value || value === '未知') return '未知';
// 将 yyyy-MM 格式转换为 yyyy年MM月
const parts = value.split('-');
if (parts.length === 2) {
return `${parts[0]}${parts[1]}`;
}
return value;
},
margin: 14,
hideOverlap: true
},
name: '',
nameTextStyle: {
fontWeight: 'bold',
color: '#606266'
},
axisLine: {
lineStyle: {
color: '#DCDFE6'
}
},
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value',
name: '',
axisLine: {
show: true,
lineStyle: {
color: '#DCDFE6'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#EBEEF5'
}
},
axisLabel: {
formatter: '{value} 次'
}
},
series: [{
name: '超标次数',
type: 'line',
smooth: true,
data: seriesData,
markPoint: {
data: [
{ type: 'max', name: '最大值' },
{ type: 'min', name: '最小值' }
],
symbolSize: 60,
itemStyle: {
color: '#F56C6C',
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
},
label: {
fontSize: 14,
fontWeight: 'bold'
}
},
markLine: {
data: [{ type: 'average', name: '平均值' }],
lineStyle: {
color: '#E6A23C',
type: 'dashed',
width: 2
},
label: {
formatter: '平均: {c} 次',
position: 'middle',
color: '#E6A23C',
fontSize: 14,
fontWeight: 'bold',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
padding: [4, 8],
borderRadius: 4
}
},
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{
offset: 0, color: 'rgba(245, 108, 108, 0.7)'
}, {
offset: 0.5, color: 'rgba(245, 108, 108, 0.3)'
}, {
offset: 1, color: 'rgba(245, 108, 108, 0.05)'
}]
},
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 10
},
lineStyle: {
color: '#f56c6c',
width: 4,
shadowColor: 'rgba(245, 108, 108, 0.5)',
shadowBlur: 10
},
itemStyle: {
color: '#f56c6c',
borderWidth: 2,
borderColor: '#fff',
shadowColor: 'rgba(0, 0, 0, 0.3)',
shadowBlur: 5
},
emphasis: {
itemStyle: {
borderWidth: 6,
shadowBlur: 20
},
lineStyle: {
width: 6
}
},
// 增加动画效果,但速度更慢
animationDuration: 3000,
animationEasing: 'cubicOut',
animationDelay: function (idx: number) {
return idx * 300;
}
}]
};
// 应用配置
chart.setOption(option);
// 响应式调整
window.addEventListener('resize', () => chart.resize());
} else {
console.warn("无法初始化趋势图表,元素不存在或数据为空",
{ hasRef: !!exceedanceTrendChartRef.value, dataLength: exceedanceTrendData.value.length });
}
};
const initPollutantTrendChart = () => {
nextTick(() => {
if (pollutantTrendChartRef.value && Object.keys(pollutantTrendData.value).length > 0) {
const chart = echarts.init(pollutantTrendChartRef.value);
// 确保数据格式正确
console.log("污染物趋势数据详情:", JSON.stringify(pollutantTrendData.value));
console.log("当前选择的污染物类型:", selectedPollutantType.value);
// 污染物类型对应的颜色
const colorMap: Record<string, string> = {
'PM25': '#ff7f50',
'O3': '#6a5acd',
'NO2': '#32cd32',
'SO2': '#ffd700',
'OTHER': '#808080'
};
// 获取所有月份
const allMonths = new Set<string>();
Object.keys(pollutantTrendData.value).forEach(type => {
pollutantTrendData.value[type].forEach(point => {
if (point.yearMonth) {
allMonths.add(point.yearMonth);
}
});
});
// 转换为数组并排序
const xAxisData = Array.from(allMonths).sort();
// 准备系列数据
let series = [];
// 设置动画时间,全部显示时稍慢,单个显示时较快
const animationDuration = selectedPollutantType.value === 'ALL' ? 2000 : 1200;
const animationDelayTime = selectedPollutantType.value === 'ALL' ? 200 : 100;
if (selectedPollutantType.value === 'ALL') {
// 显示所有污染物
series = Object.keys(pollutantTrendData.value).map(type => {
// 创建一个映射,方便查找
const dataMap = new Map<string, number>();
pollutantTrendData.value[type].forEach(point => {
if (point.yearMonth) {
dataMap.set(point.yearMonth, point.count / 100); // 除以100因为后端乘以了100
}
});
// 生成系列数据
const seriesData = xAxisData.map(month => dataMap.get(month) || 0);
return {
name: getPollutantDisplayName(type),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: seriesData,
lineStyle: {
width: 3,
color: colorMap[type] || '#5470c6'
},
itemStyle: {
color: colorMap[type] || '#5470c6'
},
emphasis: {
focus: 'series',
lineStyle: {
width: 5
}
},
animationDelay: (idx: number) => idx * animationDelayTime
};
});
} else {
// 检查选择的污染物类型是否有数据
if (!pollutantTrendData.value[selectedPollutantType.value]) {
chart.setOption({
title: {
text: `暂无${getPollutantDisplayName(selectedPollutantType.value)}趋势数据`,
left: 'center',
top: 'center',
textStyle: {
fontSize: 16,
color: '#909399'
}
}
});
return;
}
// 准备数据
const trendData = pollutantTrendData.value[selectedPollutantType.value];
// 获取月份和数据
const singleXAxisData = trendData.map(point => point.yearMonth || '未知').sort();
const seriesData = trendData.map(point => point.count / 100); // 除以100因为后端乘以了100
const color = colorMap[selectedPollutantType.value] || '#5470c6';
series = [{
name: getPollutantDisplayName(selectedPollutantType.value),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: seriesData,
markPoint: {
data: [
{ type: 'max', name: '最大值' },
{ type: 'min', name: '最小值' }
],
symbolSize: 60,
itemStyle: {
color: color,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
},
label: {
fontSize: 14,
fontWeight: 'bold'
}
},
markLine: {
data: [{ type: 'average', name: '平均值' }],
lineStyle: {
color: '#E6A23C',
type: 'dashed',
width: 2
},
label: {
formatter: '平均: {c}',
position: 'middle',
color: '#E6A23C',
fontSize: 14,
fontWeight: 'bold',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
padding: [4, 8],
borderRadius: 4
}
},
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{
offset: 0, color: `${color}CC` // 80% 透明度
}, {
offset: 0.5, color: `${color}66` // 40% 透明度
}, {
offset: 1, color: `${color}11` // 10% 透明度
}]
},
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 10
},
lineStyle: {
color: color,
width: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
shadowBlur: 10
},
itemStyle: {
color: color,
borderWidth: 2,
borderColor: '#fff',
shadowColor: 'rgba(0, 0, 0, 0.3)',
shadowBlur: 5
},
emphasis: {
itemStyle: {
borderWidth: 6,
shadowBlur: 20
},
lineStyle: {
width: 6
}
}
}];
}
const option = {
title: {
text: selectedPollutantType.value === 'ALL' ? '所有污染物浓度月度趋势' : `${getPollutantDisplayName(selectedPollutantType.value)}浓度月度趋势`,
left: 'center',
top: 0,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#303133'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
},
formatter: function(params: any) {
if (params.length === 0) return '';
let result = params[0].name + '<br/>';
params.forEach((param: any) => {
result += `${param.marker} ${param.seriesName}: <strong>${param.value.toFixed(2)}</strong><br/>`;
});
return result;
}
},
legend: {
data: selectedPollutantType.value === 'ALL'
? Object.keys(pollutantTrendData.value).map(type => getPollutantDisplayName(type))
: [getPollutantDisplayName(selectedPollutantType.value)],
top: selectedPollutantType.value === 'ALL' ? '10%' : '5%',
type: 'scroll',
textStyle: {
color: '#666'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: selectedPollutantType.value === 'ALL' ? '25%' : '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xAxisData,
axisLabel: {
interval: 'auto',
rotate: 45,
formatter: function(value: string) {
if (!value || value === '未知') return '未知';
// 将 yyyy-MM 格式转换为 yyyy年MM月
const parts = value.split('-');
if (parts.length === 2) {
return `${parts[0]}${parts[1]}`;
}
return value;
},
margin: 14,
hideOverlap: true
},
name: '',
nameTextStyle: {
fontWeight: 'bold',
color: '#606266'
}
},
yAxis: {
type: 'value',
name: '',
axisLine: {
show: true
},
splitLine: {
lineStyle: {
type: 'dashed'
}
},
axisLabel: {
formatter: '{value} μg/m³',
margin: 16
}
},
series: series,
animationDuration: animationDuration,
animationEasing: 'cubicOut' as const,
animationDelay: function(idx: number) {
return idx * animationDelayTime;
}
};
// 应用配置
chart.setOption(option);
// 响应式调整
window.addEventListener('resize', () => chart.resize());
} else {
console.warn("无法初始化污染物趋势图表,元素不存在或数据为空",
{ hasRef: !!pollutantTrendChartRef.value, dataLength: Object.keys(pollutantTrendData.value).length });
}
});
};
// --- Utility Functions ---
const getCoverageColor = (percentage: number) => {
if (percentage < 50) return '#f56c6c';
if (percentage < 80) return '#e6a23c';
return '#67c23a';
};
// 获取污染物显示名称
const getPollutantDisplayName = (type: string): string => {
const nameMap: Record<string, string> = {
'PM25': 'PM2.5',
'O3': '臭氧(O₃)',
'NO2': '二氧化氮(NO₂)',
'SO2': '二氧化硫(SO₂)',
'OTHER': '其他污染物'
};
return nameMap[type] || type;
};
const percentageFormat = (percentage: number) => {
return `${percentage}%`;
};
const tableRowClassName = ({ row, rowIndex }: { row: any, rowIndex: number }) => {
if (rowIndex % 2 === 0) {
return 'even-row';
}
return 'odd-row';
};
const coverageTableRowClassName = ({ row }: { row: any }) => {
if (row.coverageRate < 50) return 'coverage-row-low';
if (row.coverageRate < 80) return 'coverage-row-medium';
return 'coverage-row-high';
};
// --- Data Fetching ---
const refreshAqiData = () => {
loading.aqi = true;
getAqiDistribution().then(data => {
aqiDistributionData.value = data;
}).catch(e => {
console.error('获取AQI分布数据失败:', e);
ElMessage.error('获取AQI分布数据失败');
}).finally(() => {
loading.aqi = false;
nextTick(initAqiChart);
});
};
const refreshTaskData = () => {
loading.tasks = true;
getTaskCompletionStats().then(data => {
taskStatusData.value = data;
}).catch(e => {
console.error('获取任务统计数据失败:', e);
ElMessage.error('获取任务统计数据失败');
}).finally(() => {
loading.tasks = false;
nextTick(initTaskChart);
});
};
const refreshTrendData = () => {
loading.trend = true;
getMonthlyExceedanceTrend().then(data => {
console.log("获取到的月度趋势数据:", JSON.stringify(data));
exceedanceTrendData.value = data;
}).catch(e => {
console.error('获取月度趋势数据失败:', e);
ElMessage.error('获取月度趋势数据失败');
}).finally(() => {
loading.trend = false;
nextTick(() => {
console.log("准备初始化趋势图,数据:", JSON.stringify(exceedanceTrendData.value));
initTrendChart();
});
});
};
const refreshCoverageData = () => {
loading.coverage = true;
getGridCoverageByCity().then(data => {
gridCoverageData.value = data;
}).catch(e => {
console.error('获取网格覆盖率数据失败:', e);
ElMessage.error('获取网格覆盖率数据失败');
}).finally(() => {
loading.coverage = false;
});
};
const refreshPollutantTrendData = async () => {
console.log('开始获取污染物趋势数据...');
loading.pollutantTrend = true;
errors.pollutantTrend = false;
try {
const data = await getPollutantMonthlyTrends(selectedPollutantType.value);
console.log('获取到的污染物趋势数据:', data);
pollutantTrendData.value = data;
} catch (error) {
console.error('获取污染物趋势数据失败:', error);
errors.pollutantTrend = true;
pollutantTrendData.value = {}; // Clear data on error, ensure it's an object
} finally {
loading.pollutantTrend = false;
console.log('污染物趋势数据获取流程结束。');
}
};
const refreshAllData = () => {
console.log("开始刷新所有数据...");
loading.stats = true;
loading.aqi = true;
loading.tasks = true;
loading.trend = true;
loading.coverage = true;
loading.pollutantTrend = true;
getDashboardStats().then(data => {
console.log("获取到的关键指标数据:", data);
statsData.value = data;
}).catch(e => {
console.error('获取关键指标失败:', e);
ElMessage.error('获取关键指标失败');
}).finally(() => {
loading.stats = false;
});
refreshAqiData();
refreshTaskData();
refreshTrendData();
refreshCoverageData();
refreshPollutantTrendData();
console.log("所有数据刷新请求已发送");
};
// --- Lifecycle Hooks ---
watch(pollutantTrendData, (newData) => {
if (newData && Object.keys(newData).length > 0) {
initPollutantTrendChart();
}
}, { deep: true });
onMounted(() => {
console.log("组件挂载,开始加载数据...");
if (canViewDashboard.value) {
refreshAllData();
timer.value = setInterval(refreshAllData, 300000); // 5分钟刷新一次
} else {
console.log(`用户角色: ${user.value?.role}, 无需加载仪表盘数据。`);
}
});
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value);
}
clearInterval(timeInterval);
});
// --- Navigation ---
const goBack = () => {
router.back();
};
// 详细数据对话框
const detailDialogVisible = ref(false);
const detailDialogTitle = ref('');
const detailTableData = ref<any[]>([]);
const detailTableColumns = ref<any[]>([]);
// 显示AQI详细数据
const showAqiDetailData = () => {
detailDialogTitle.value = 'AQI级别分布详细数据';
detailTableData.value = aqiDistributionData.value.map(item => ({
level: item.level,
count: item.count,
percentage: ((item.count / aqiDistributionData.value.reduce((sum, i) => sum + i.count, 0)) * 100).toFixed(2) + '%'
}));
detailTableColumns.value = [
{ prop: 'level', label: 'AQI级别', width: '180' },
{ prop: 'count', label: '记录数量', width: '120' },
{ prop: 'percentage', label: '百分比', width: '120' }
];
detailDialogVisible.value = true;
};
// 显示任务详细数据
const showTaskDetailData = () => {
detailDialogTitle.value = '任务状态分布详细数据';
detailTableData.value = [
{ status: '已完成', count: taskStatusData.value.completedTasks, percentage: taskStatusData.value.completionRate.toFixed(2) + '%' },
{ status: '未完成', count: taskStatusData.value.totalTasks - taskStatusData.value.completedTasks, percentage: (100 - taskStatusData.value.completionRate).toFixed(2) + '%' }
];
detailTableColumns.value = [
{ prop: 'status', label: '任务状态', width: '180' },
{ prop: 'count', label: '任务数量', width: '120' },
{ prop: 'percentage', label: '百分比', width: '120' }
];
detailDialogVisible.value = true;
};
// 显示超标趋势详细数据
const showTrendDetailData = () => {
detailDialogTitle.value = '月度超标趋势详细数据';
detailTableData.value = exceedanceTrendData.value.map(item => ({
yearMonth: item.yearMonth || '未知',
count: item.count
})).sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
detailTableColumns.value = [
{ prop: 'yearMonth', label: '年月', width: '180' },
{ prop: 'count', label: '超标次数', width: '120' }
];
detailDialogVisible.value = true;
};
// 显示污染物趋势详细数据
const showPollutantDetailData = () => {
detailDialogTitle.value = '污染物趋势详细数据';
if (selectedPollutantType.value === 'ALL') {
// 获取所有月份
const allMonths = new Set<string>();
Object.keys(pollutantTrendData.value).forEach(type => {
pollutantTrendData.value[type].forEach(point => {
if (point.yearMonth) {
allMonths.add(point.yearMonth);
}
});
});
// 转换为数组并排序
const months = Array.from(allMonths).sort();
// 创建数据映射
const dataMap: Record<string, Record<string, number>> = {};
months.forEach(month => {
dataMap[month] = {};
Object.keys(pollutantTrendData.value).forEach(type => {
dataMap[month][type] = 0;
});
});
// 填充数据
Object.keys(pollutantTrendData.value).forEach(type => {
pollutantTrendData.value[type].forEach(point => {
if (point.yearMonth) {
dataMap[point.yearMonth][type] = point.count / 100;
}
});
});
// 转换为表格数据
detailTableData.value = months.map(month => {
const row: any = { yearMonth: month };
Object.keys(pollutantTrendData.value).forEach(type => {
row[type] = dataMap[month][type].toFixed(2);
});
return row;
});
// 创建表格列
detailTableColumns.value = [
{ prop: 'yearMonth', label: '年月', width: '120' },
...Object.keys(pollutantTrendData.value).map(type => ({
prop: type,
label: getPollutantDisplayName(type),
width: '120'
}))
];
} else {
// 单个污染物
const type = selectedPollutantType.value;
detailTableData.value = pollutantTrendData.value[type].map(item => ({
yearMonth: item.yearMonth || '未知',
value: (item.count / 100).toFixed(2)
})).sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
detailTableColumns.value = [
{ prop: 'yearMonth', label: '年月', width: '180' },
{ prop: 'value', label: `${getPollutantDisplayName(type)}浓度 (μg/m³)`, width: '180' }
];
}
detailDialogVisible.value = true;
};
// 关闭详细数据对话框
const closeDetailDialog = () => {
detailDialogVisible.value = false;
};
// 导出详细数据
const exportDetailData = () => {
// 创建CSV内容
let csvContent = '';
// 添加表头
csvContent += detailTableColumns.value.map(col => `"${col.label}"`).join(',') + '\n';
// 添加数据行
detailTableData.value.forEach(row => {
const rowData = detailTableColumns.value.map(col => {
const value = row[col.prop];
return `"${value !== undefined ? value : ''}"`;
});
csvContent += rowData.join(',') + '\n';
});
// 创建Blob对象
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
// 创建下载链接
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${detailDialogTitle.value}.csv`);
link.style.visibility = 'hidden';
// 添加到DOM并触发点击
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
ElMessage.success('数据导出成功');
};
</script>
<style scoped>
.dashboard-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #e4ebf5 100%);
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 10;
}
.dashboard-header h1 {
font-size: 24px;
margin: 0;
color: #303133;
font-weight: 600;
display: flex;
align-items: center;
}
.dashboard-header h1::before {
content: '';
display: inline-block;
width: 6px;
height: 24px;
background: linear-gradient(to bottom, #409EFF, #67C23A);
margin-right: 12px;
border-radius: 3px;
}
.dashboard-actions {
display: flex;
gap: 12px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto auto auto;
gap: 20px;
padding: 24px;
background-color: #f5f7fa;
flex: 1;
}
.grid-item {
border-radius: 12px;
background-color: #fff;
transition: all 0.3s ease;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.grid-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.kpi-card {
grid-column: span 1;
grid-row: 1 / 2;
position: relative;
}
.kpi-card::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, rgba(103, 194, 58, 0.6) 0%, rgba(103, 194, 58, 1) 100%);
transform: scaleX(0.7);
transform-origin: left;
transition: transform 0.3s ease;
}
.kpi-card:hover::after {
transform: scaleX(1);
}
.chart-card {
grid-column: span 2;
height: 350px; /* Ensure charts have a consistent height */
position: relative;
overflow: hidden;
}
.chart-card::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
height: 6px;
background: linear-gradient(90deg, #409EFF, #67C23A);
opacity: 0.7;
border-radius: 6px 6px 0 0;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 16px 0;
border-bottom: 1px dashed #EBEEF5;
margin-bottom: 16px;
}
.chart-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chart-content {
position: relative;
height: calc(100% - 50px);
}
.chart-container {
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-overlay .el-icon {
font-size: 24px;
color: #409EFF;
}
.aqi-distribution {
grid-column: 1 / 3;
grid-row: 2 / 3;
}
.task-status {
grid-column: 3 / 5;
grid-row: 2 / 3;
}
.exceedance-trend {
grid-column: 1 / 3;
grid-row: 3 / 4;
}
.pollutant-trend {
grid-column: 3 / 5;
grid-row: 3 / 4;
}
.grid-coverage {
grid-column: 1 / 5;
grid-row: 4 / 5;
}
.kpi-content {
display: flex;
align-items: center;
padding: 16px;
}
.kpi-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
font-size: 28px;
margin-right: 20px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
position: relative;
overflow: hidden;
}
.kpi-icon::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 70%);
}
.kpi-details {
display: flex;
flex-direction: column;
}
.kpi-title {
font-size: 16px;
color: #606266;
margin-bottom: 6px;
font-weight: 500;
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #303133;
position: relative;
}
.kpi-value::after {
content: '';
position: absolute;
bottom: -6px;
left: 0;
width: 30px;
height: 3px;
background-color: #409EFF;
border-radius: 3px;
}
.card-header {
font-weight: bold;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
color: #303133;
width: 100%;
justify-content: space-between;
}
.card-header span {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 20px;
color: #409EFF;
}
.card-actions {
display: flex;
gap: 8px;
}
.chart {
width: 100%;
height: 100%;
padding: 10px;
}
:deep(.el-card__header) {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
background-color: #f8f9fb;
}
:deep(.el-card__body) {
padding: 16px;
height: calc(100% - 53px); /* Adjust based on header height */
box-sizing: border-box;
}
.loading-text {
color: #c0c4cc;
}
.coverage-header {
background-color: #f8f9fb;
color: #303133;
font-weight: bold;
}
.coverage-body {
padding: 16px;
height: calc(100% - 53px);
box-sizing: border-box;
}
.coverage-row-low {
color: #f56c6c;
font-weight: 500;
}
.coverage-row-medium {
color: #e6a23c;
font-weight: 500;
}
.coverage-row-high {
color: #67c23a;
font-weight: 500;
}
.coverage-row-low td,
.coverage-row-medium td,
.coverage-row-high td {
background-color: transparent !important;
}
:deep(.el-progress-bar__outer) {
border-radius: 10px;
background-color: #ebeef5;
}
:deep(.el-progress-bar__inner) {
border-radius: 10px;
transition: all 0.6s ease;
}
:deep(.el-progress__text) {
font-weight: bold;
}
:deep(.el-table th) {
background-color: #f8f9fb !important;
color: #303133;
font-weight: bold;
padding: 12px 0;
}
:deep(.el-table td) {
padding: 12px 0;
}
:deep(.el-table--border) {
border-radius: 8px;
overflow: hidden;
}
.dashboard-footer {
text-align: center;
padding: 16px;
background-color: #fff;
border-top: 1px solid #e4e7ed;
color: #909399;
font-size: 14px;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 10;
}
.even-row {
background-color: #f9f9f9;
}
.odd-row {
background-color: #ffffff;
}
.even-row td,
.odd-row td {
transition: background-color 0.3s;
}
.even-row:hover td,
.odd-row:hover td {
background-color: #ecf5ff !important;
}
.detail-dialog {
border-radius: 16px;
overflow: hidden;
}
.detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #409EFF, #67C23A);
padding: 20px;
border-bottom: none;
position: relative;
}
.detail-dialog :deep(.el-dialog__title) {
font-size: 20px;
font-weight: 600;
color: #fff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
.detail-dialog :deep(.el-dialog__headerbtn) {
top: 20px;
right: 20px;
}
.detail-dialog :deep(.el-dialog__headerbtn .el-dialog__close) {
color: #fff;
font-size: 20px;
}
.detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
background-color: #f9fafc;
}
.detail-table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
background-color: #fff;
position: relative;
}
.detail-table-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 6px;
background: linear-gradient(90deg, #409EFF, #67C23A);
z-index: 1;
}
.detail-dialog-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 16px 20px;
background-color: #f9fafc;
border-top: 1px solid #e4e7ed;
}
.even-row {
background-color: #f9fafc;
}
.odd-row {
background-color: #ffffff;
}
.even-row td,
.odd-row td {
transition: all 0.3s;
border-bottom: 1px solid #ebeef5;
}
.even-row:hover td,
.odd-row:hover td {
background-color: #ecf5ff !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.welcome-container {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<span>环境监督系统</span>
</div>
</template>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form" label-position="top" autocomplete="off" @submit.prevent="handleLogin">
<el-form-item label="邮箱" prop="email" class="form-item-tight">
<el-input v-model="loginForm.email" placeholder="请输入邮箱" size="large" :prefix-icon="User" clearable autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password" class="form-item-tight">
<el-input type="password" v-model="loginForm.password" placeholder="请输入密码" size="large" :prefix-icon="Lock" show-password autocomplete="new-password"></el-input>
</el-form-item>
<el-form-item class="login-btn-item">
<el-button type="primary" @click="handleLogin" :loading="loading" class="login-button" size="large"> </el-button>
</el-form-item>
<div class="extra-links">
<el-link type="info" @click="$router.push('/register')">注册账号</el-link>
<el-link type="info" @click="$router.push('/forgot-password')">找回密码</el-link>
</div>
<el-form-item class="guest-btn-item">
<el-button @click="handleGuestAccess" class="guest-button" size="large" :icon="Promotion">作为游客提交反馈</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import { User, Lock, Promotion } from '@element-plus/icons-vue';
const router = useRouter();
const authStore = useAuthStore();
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
const loginForm = reactive({
email: '',
password: '',
});
const loginRules = reactive<FormRules>({
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
});
const handleLogin = async () => {
if (!loginFormRef.value) return;
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
await authStore.login(loginForm);
ElMessage.success('登录成功,正在跳转...');
} catch (error) {
ElMessage.error('登录失败,请检查您的邮箱和密码。');
console.error(error);
} finally {
loading.value = false;
}
}
});
};
const handleGuestAccess = () => {
router.push('/public-feedback');
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
overflow: hidden; /* Prevent scrolling */
background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80');
background-size: cover;
background-position: center;
}
.login-card {
position: relative;
width: 480px;
border-radius: 25px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.7s ease-in-out;
background: rgba(245, 245, 245, 0.85); /* Light warm gray */
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-header {
text-align: center;
font-size: 26px;
font-weight: 600;
color: #004d40; /* Teal Gold color */
padding: 25px 0 0 0; /* Removed bottom padding */
border-bottom: 1px solid rgba(0,77,64,0.2);
}
.login-form {
padding: 10px 35px 20px 35px; /* Minimal top padding */
}
.form-item-tight {
margin-bottom: 18px;
}
.login-button {
width: 100%;
border: none;
background: #004d40; /* Teal Gold color */
color: white;
letter-spacing: 5px;
padding-left: 10px; /* to compensate letter-spacing */
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 77, 64, 0.4);
border-radius: 10px;
}
.login-button:hover {
background: #00695c;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 77, 64, 0.5);
}
.login-btn-item {
margin-top: 25px;
margin-bottom: 0;
}
.el-input__wrapper {
transition: all 0.3s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
}
.extra-links {
display: flex;
justify-content: space-between;
margin-top: 20px;
padding: 0 10px;
}
.divider-text {
color: #888;
font-size: 14px;
}
.guest-btn-item {
margin-top: 15px;
}
.guest-button {
width: 100%;
border-color: #004d40;
color: #004d40;
}
.guest-button:hover {
background-color: #e0f2f1;
border-color: #00695c;
color: #00695c;
}
.extra-links .el-link {
color: #bbb;
font-size: 15px; /* Increased font size */
}
.extra-links .el-link:hover {
color: #fff;
}
</style>
<style>
/* This is a global style override to achieve the desired hover/focus effect */
.el-input__wrapper {
transition: all 0.3s ease !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--el-color-primary, #409eff) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary, #409eff) inset !important;
}
.el-form-item__label {
color: #555 !important;
font-weight: 500 !important;
letter-spacing: 0.5px !important;
font-size: 15px !important;
}
.el-input__inner {
color: #333 !important;
font-size: 15px !important; /* Matched font size to label */
}
.el-input__icon {
font-size: 18px !important; /* Icon size can be slightly larger */
}
.el-input__wrapper {
background-color: rgba(255, 255, 255, 0.9) !important;
border-radius: 10px !important;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div>
<h1>地图模块</h1>
<p>这里是地图模块页面</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,58 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>我的操作日志</span>
</div>
</template>
<el-table :data="logs" v-loading="loading" style="width: 100%" border stripe>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="ipAddress" label="IP地址" width="150"></el-table-column>
<el-table-column prop="operationTypeDesc" label="操作类型" width="120"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="targetId" label="对象ID" width="100"></el-table-column>
<el-table-column prop="targetType" label="对象类型" width="120"></el-table-column>
<el-table-column prop="createdAt" label="操作时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { getMyOperationLogs } from '@/api/log';
import type { OperationLog } from '@/api/types';
const loading = ref(true);
const logs = ref<OperationLog[]>([]);
const fetchLogs = async () => {
loading.value = true;
try {
logs.value = await getMyOperationLogs();
} catch (error) {
ElMessage.error('获取操作日志失败');
console.error(error);
} finally {
loading.value = false;
}
};
const formatTime = (timeStr: string) => {
if (!timeStr) return '';
return new Date(timeStr).toLocaleString();
};
onMounted(() => {
fetchLogs();
});
</script>
<style scoped>
/* You can add styles if needed */
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div class="my-tasks-view">
<el-card class="page-card">
<template #header>
<div class="card-header">
<span>我的任务</span>
</div>
</template>
<!-- 筛选区域 -->
<div class="filter-container">
<el-radio-group v-model="selectedStatus" @change="fetchTasks">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="ASSIGNED">待接受</el-radio-button>
<el-radio-button value="IN_PROGRESS">进行中</el-radio-button>
<el-radio-button value="SUBMITTED">已提交</el-radio-button>
<el-radio-button value="COMPLETED">已完成</el-radio-button>
</el-radio-group>
</div>
<!-- 任务列表 -->
<el-table :data="tasks" v-loading="loading" stripe class="task-table" @row-click="handleRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">{{ formatStatus(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="textAddress" label="地址" min-width="250" show-overflow-tooltip />
<el-table-column prop="severity" label="严重程度" width="100">
<template #default="{ row }">
<el-tag :type="getSeverityTagType(row.severity)">{{ formatSeverity(row.severity) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="分配时间" width="180">
<template #default="{ row }">
<span>{{ new Date(row.createdAt).toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewTaskDetails(row.id)">查看详情</el-button>
<el-button v-if="row.status === 'ASSIGNED'" type="success" link size="small" @click="acceptTask(row.id)">接受</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import apiClient from '@/api';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { Page } from '@/api/types';
import { useRouter } from 'vue-router';
// --- 类型定义 ---
interface TaskSummary {
id: number;
title: string;
description: string;
status: string;
assigneeName: string | null;
createdAt: string;
textAddress: string;
imageUrl: string;
severity: string;
}
// --- 响应式状态 ---
const tasks = ref<TaskSummary[]>([]);
const loading = ref(false);
const selectedStatus = ref<string | null>(null);
const total = ref(0);
const pagination = reactive({
page: 1,
size: 10,
});
const router = useRouter();
// --- API 调用 ---
const fetchTasks = async () => {
loading.value = true;
try {
const params: any = {
page: pagination.page - 1,
size: pagination.size,
sort: 'createdAt,desc'
};
if (selectedStatus.value) {
params.status = selectedStatus.value;
}
const response = await apiClient.get<Page<TaskSummary>>('/worker', { params });
tasks.value = response.content;
total.value = response.totalElements;
} catch (error) {
console.error('获取任务列表失败:', error);
ElMessage.error('获取任务列表失败');
tasks.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const acceptTask = async (taskId: number) => {
try {
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
});
await apiClient.post(`/worker/${taskId}/accept`);
ElMessage.success('任务已接受');
fetchTasks(); // 刷新列表
} catch (error) {
if (error !== 'cancel') {
console.error('接受任务失败:', error);
ElMessage.error('接受任务失败');
}
}
};
// --- 辅助函数 ---
const formatStatus = (status: string) => {
const map: Record<string, string> = {
'ASSIGNED': '待接受',
'IN_PROGRESS': '进行中',
'SUBMITTED': '已提交',
'COMPLETED': '已完成',
'REJECTED': '已拒绝',
};
return map[status] || status;
};
const getStatusTagType = (status: string): 'primary' | 'warning' | 'info' | 'success' | 'danger' => {
const map: Record<string, 'primary' | 'warning' | 'info' | 'success' | 'danger'> = {
'ASSIGNED': 'primary',
'IN_PROGRESS': 'warning',
'SUBMITTED': 'info',
'COMPLETED': 'success',
'REJECTED': 'danger',
};
return map[status] || 'info';
};
const formatSeverity = (severity: string) => {
const map: Record<string, string> = {
'LOW': '低',
'MEDIUM': '中',
'HIGH': '高',
};
return map[severity] || severity;
};
const getSeverityTagType = (severity: string): 'info' | 'warning' | 'danger' => {
const map: Record<string, 'info' | 'warning' | 'danger'> = {
'LOW': 'info',
'MEDIUM': 'warning',
'HIGH': 'danger',
};
return map[severity] || 'info';
};
// --- 事件处理 ---
const viewTaskDetails = (taskId: number) => {
router.push(`/my-tasks/${taskId}`);
};
const handleRowClick = (row: TaskSummary) => {
viewTaskDetails(row.id);
};
const handleSizeChange = (size: number) => {
pagination.size = size;
fetchTasks();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
fetchTasks();
};
// --- 生命周期钩子 ---
onMounted(() => {
fetchTasks();
});
</script>
<style scoped>
.my-tasks-view {
padding: 20px;
}
.page-card {
border-radius: 8px;
}
.card-header {
font-size: 18px;
font-weight: 600;
}
.filter-container {
margin-bottom: 20px;
}
.task-table {
width: 100%;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>操作日志</span>
</div>
</template>
<div class="filter-container">
<el-input v-model="filters.userId" placeholder="按用户ID搜索" style="width: 200px;"></el-input>
<el-select v-model="filters.operationType" placeholder="按操作类型筛选" clearable>
<el-option v-for="item in operationTypes" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
<el-date-picker
v-model="filters.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期">
</el-date-picker>
<el-button type="primary" :icon="Search" @click="fetchLogs">搜索</el-button>
</div>
<el-table :data="logs" v-loading="loading" style="width: 100%" border stripe>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="userName" label="用户名" width="120"></el-table-column>
<el-table-column prop="ipAddress" label="IP地址" width="150"></el-table-column>
<el-table-column prop="operationTypeDesc" label="操作类型" width="120"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="targetId" label="对象ID" width="100"></el-table-column>
<el-table-column prop="targetType" label="对象类型" width="120"></el-table-column>
<el-table-column prop="createdAt" label="操作时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import { getOperationLogs } from '@/api/log';
import type { OperationLog } from '@/api/types';
const loading = ref(true);
const logs = ref<OperationLog[]>([]);
const filters = reactive({
userId: '',
operationType: '',
timeRange: [] as [Date, Date] | [],
});
const operationTypes = [
{ label: "登录", value: "LOGIN" },
{ label: "登出", value: "LOGOUT" },
{ label: "创建", value: "CREATE" },
{ label: "更新", value: "UPDATE" },
{ label: "删除", value: "DELETE" },
{ label: "分配任务", value: "ASSIGN_TASK" },
{ label: "提交任务", value: "SUBMIT_TASK" },
{ label: "审批任务", value: "APPROVE_TASK" },
{ label: "拒绝任务", value: "REJECT_TASK" },
{ label: "提交反馈", value: "SUBMIT_FEEDBACK" },
{ label: "审批反馈", value: "APPROVE_FEEDBACK" },
{ label: "拒绝反馈", value: "REJECT_FEEDBACK" },
{ label: "其他操作", value: "OTHER" }
];
const fetchLogs = async () => {
loading.value = true;
try {
const params: any = {
userId: filters.userId || undefined,
operationType: filters.operationType || undefined,
startTime: filters.timeRange?.[0]?.toISOString(),
endTime: filters.timeRange?.[1]?.toISOString(),
};
logs.value = await getOperationLogs(params);
} catch (error) {
ElMessage.error('获取操作日志失败');
console.error(error);
} finally {
loading.value = false;
}
};
const formatTime = (timeStr: string) => {
if (!timeStr) return '';
return new Date(timeStr).toLocaleString();
};
onMounted(() => {
fetchLogs();
});
</script>
<style scoped>
.filter-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="public-submit-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>公共环境问题反馈</span>
<p>感谢您为环境保护出一份力您可以在此提交发现的环境问题无需登录</p>
</div>
</template>
<el-form :model="form" :rules="rules" ref="feedbackForm" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入反馈标题"></el-input>
</el-form-item>
<el-form-item label="问题描述" prop="description">
<el-input type="textarea" :rows="4" v-model="form.description" placeholder="请详细描述您发现的问题"></el-input>
</el-form-item>
<el-form-item label="污染类型" prop="pollutionType">
<el-select v-model="form.pollutionType" placeholder="请选择污染类型">
<el-option v-for="(label, key) in pollutionTypeMap" :key="key" :label="label" :value="key"></el-option>
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="severityLevel">
<el-rate v-model="rating" :max="3" :texts="['低', '中', '高']" show-text></el-rate>
</el-form-item>
<el-form-item label="联系方式" prop="contactInfo">
<el-input v-model="form.contactInfo" placeholder="选填,便于我们与您联系(邮箱或电话)"></el-input>
</el-form-item>
<el-form-item label="问题地址" prop="textAddress">
<el-input v-model="form.textAddress" placeholder="请输入问题发生的地址"></el-input>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="经度" prop="longitude">
<el-input-number v-model="form.longitude" :precision="6" :step="0.000001" placeholder="请输入经度"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="纬度" prop="latitude">
<el-input-number v-model="form.latitude" :precision="6" :step="0.000001" placeholder="请输入纬度"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="网格X" prop="gridX">
<el-input-number v-model="form.gridX" :precision="0" :step="1" placeholder="请输入网格X坐标"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="网格Y" prop="gridY">
<el-input-number v-model="form.gridY" :precision="0" :step="1" placeholder="请输入网格Y坐标"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="照片/视频附件">
<el-upload
ref="upload"
class="upload-demo"
action="#"
:on-remove="handleRemove"
:on-change="handleChange"
:file-list="fileList"
list-type="picture-card"
:auto-upload="false"
multiple
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="loading">立即提交</el-button>
<el-button @click="resetForm">重置表单</el-button>
<el-button @click="$router.push('/login')">返回登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { PollutionType, SeverityLevel } from '@/api/feedback';
import { submitPublicFeedback } from '@/api/public'; // Assuming the function will be in a new api/public.ts
const feedbackForm = ref<FormInstance>();
const loading = ref(false);
const fileList = ref([]);
const rating = ref(2);
interface PublicFeedbackForm {
title: string;
description: string;
pollutionType: PollutionType;
severityLevel: SeverityLevel;
contactInfo?: string;
textAddress: string;
latitude?: number;
longitude?: number;
gridX?: number;
gridY?: number;
}
const form = reactive({
title: '',
description: '',
pollutionType: PollutionType.OTHER,
contactInfo: '',
textAddress: '',
latitude: 0,
longitude: 0,
gridX: 0,
gridY: 0,
});
const pollutionTypeMap: Record<PollutionType, string> = {
[PollutionType.PM25]: 'PM2.5',
[PollutionType.O3]: '臭氧 (O3)',
[PollutionType.NO2]: '二氧化氮 (NO2)',
[PollutionType.SO2]: '二氧化硫 (SO2)',
[PollutionType.OTHER]: '其他',
};
const rules = reactive<FormRules>({
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
description: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
pollutionType: [{ required: true, message: '请选择污染类型', trigger: 'change' }],
textAddress: [{ required: true, message: '请输入问题地址', trigger: 'blur' }],
gridX: [{ required: true, type: 'number', message: '网格X必须为数字' }],
gridY: [{ required: true, type: 'number', message: '网格Y必须为数字' }],
});
const handleRemove = (file, fileListUpdated) => {
fileList.value = fileListUpdated;
};
const handleChange = (file, fileListUpdated) => {
fileList.value = fileListUpdated;
}
const submitForm = async () => {
if (!feedbackForm.value) return;
await feedbackForm.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
let severity: SeverityLevel;
if (rating.value === 1) severity = SeverityLevel.LOW;
else if (rating.value === 2) severity = SeverityLevel.MEDIUM;
else severity = SeverityLevel.HIGH;
const submissionData: PublicFeedbackForm = { ...form, severityLevel: severity };
const formData = new FormData();
formData.append('feedback', new Blob([JSON.stringify(submissionData)], { type: 'application/json' }));
fileList.value.forEach(file => {
formData.append('files', file.raw);
});
await submitPublicFeedback(formData);
ElMessage.success('反馈提交成功!感谢您的贡献。');
resetForm();
} catch (error) {
ElMessage.error('提交失败,请稍后再试。');
} finally {
loading.value = false;
}
}
});
};
const resetForm = () => {
feedbackForm.value?.resetFields();
fileList.value = [];
rating.value = 2;
};
</script>
<style scoped>
.public-submit-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
background-color: #f0f2f5;
}
.box-card {
width: 800px;
}
.card-header {
font-size: 1.2em;
font-weight: bold;
text-align: center;
}
.card-header p {
font-size: 0.8em;
font-weight: normal;
color: #666;
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div class="register-container">
<el-card class="register-card">
<template #header>
<div class="card-header">
<span>创建您的账户</span>
</div>
</template>
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-position="top" hide-required-asterisk autocomplete="off">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="registerForm.name" placeholder="您的真实姓名" size="large" autocomplete="off"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="registerForm.phone" placeholder="您的手机号" size="large" autocomplete="off"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="邮箱" prop="email">
<el-input v-model="registerForm.email" placeholder="您的邮箱" size="large" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="验证码" prop="verificationCode">
<el-row :gutter="10" style="width: 100%;">
<el-col :span="15" class="verification-code-input">
<el-input v-model="registerForm.verificationCode" placeholder="请输入6位验证码" size="large"></el-input>
</el-col>
<el-col :span="9">
<el-button @click="sendVerificationCode" :disabled="isSendingCode || countdown > 0" :loading="isSendingCode" class="send-code-button" size="large">
{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}
</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="registerForm.password" placeholder="设置您的密码" show-password size="large" autocomplete="new-password"></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input type="password" v-model="registerForm.confirmPassword" placeholder="请再次输入您的密码" show-password size="large" autocomplete="new-password"></el-input>
</el-form-item>
<el-form-item label="注册身份" prop="role">
<el-select v-model="registerForm.role" placeholder="请选择您的身份" style="width: 100%;" size="large">
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
<el-option label="网格员" value="GRID_WORKER"></el-option>
<el-option label="业务主管" value="SUPERVISOR"></el-option>
<el-option label="系统管理员" value="ADMIN"></el-option>
<el-option label="决策者" value="DECISION_MAKER"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleRegister" :loading="isRegistering" class="register-button">立即注册</el-button>
</el-form-item>
<div class="extra-links">
<el-link type="info" @click="$router.push('/login')">已有账户返回登录</el-link>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage } from 'element-plus';
import { signup, sendVerificationCode as apiSendCode } from '@/api/auth';
const router = useRouter();
const registerFormRef = ref<FormInstance>();
const isRegistering = ref(false);
const registerForm = reactive({
name: '',
phone: '',
email: '',
password: '',
confirmPassword: '',
role: '',
verificationCode: '',
});
const validatePass2 = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'));
} else if (value !== registerForm.password) {
callback(new Error("两次输入的密码不一致!"));
} else {
callback();
}
};
const validatePhone = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请输入手机号'));
} else if (!/^1\d{10}$/.test(value)) {
callback(new Error('手机号必须是以1开头的11位数字'));
} else {
callback();
}
};
const registerRules = reactive<FormRules>({
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }],
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
confirmPassword: [{ validator: validatePass2, trigger: 'blur' }],
role: [{ required: true, message: '请选择注册身份', trigger: 'change' }],
verificationCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
});
const isSendingCode = ref(false);
const countdown = ref(0);
let timer: number | null = null;
const sendVerificationCode = async () => {
if (!registerForm.email) {
ElMessage.warning('请输入您的邮箱地址');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(registerForm.email)) {
ElMessage.warning('请输入有效的邮箱地址');
return;
}
isSendingCode.value = true;
try {
await apiSendCode(registerForm.email);
ElMessage.success('验证码已发送,请注意查收');
countdown.value = 60;
if(timer) clearInterval(timer);
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
if(timer) clearInterval(timer);
timer = null;
}
}, 1000);
} catch (error) {
ElMessage.error('验证码发送失败,请稍后重试');
console.error(error);
} finally {
isSendingCode.value = false;
}
};
const handleRegister = async () => {
if (!registerFormRef.value) return;
await registerFormRef.value.validate(async (valid) => {
if (valid) {
isRegistering.value = true;
try {
const { confirmPassword, ...signupData } = registerForm;
await signup(signupData);
ElMessage.success('注册成功!正在跳转到登录页面...');
setTimeout(() => {
router.push('/login');
}, 1500);
} catch (error) {
ElMessage.error('注册失败,请检查您填写的信息。');
console.error(error);
} finally {
isRegistering.value = false;
}
}
});
};
</script>
<style scoped>
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 100vw;
overflow-y: auto; /* Allow scrolling only if card is too tall */
padding: 40px 0;
background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80');
background-size: cover;
background-position: center;
}
.register-card {
width: 550px; /* Wider card for more fields */
border-radius: 25px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
background: rgba(245, 245, 245, 0.85);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.card-header {
text-align: center;
font-size: 26px;
font-weight: 600;
color: #004d40;
padding: 25px 0 15px 0;
border-bottom: 1px solid rgba(0,77,64,0.2);
}
.register-button {
width: 100%;
border: none;
background: #004d40;
color: white;
letter-spacing: 5px;
padding-left: 10px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 77, 64, 0.4);
border-radius: 10px;
font-size: 16px;
height: 45px;
}
.register-button:hover {
background: #00695c;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 77, 64, 0.5);
}
.extra-links {
text-align: center;
margin-top: 15px;
}
.extra-links .el-link {
color: #555;
font-size: 15px;
}
.extra-links .el-link:hover {
color: #004d40;
}
/* Add scoped styles for the verification code input and button */
:deep(.verification-code-input .el-input__wrapper) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.send-code-button {
width: 100%;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
background-color: #004d40;
color: white;
border-color: #004d40;
}
.send-code-button:hover {
background-color: #00695c;
border-color: #00695c;
}
</style>
<style>
/* Global overrides to match login page style */
:deep(.register-container .el-input__wrapper),
:deep(.register-container .el-select .el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.9) !important;
border-radius: 10px !important;
height: 45px;
box-shadow: none !important;
border: 1px solid transparent !important;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
:deep(.register-container .el-input__wrapper:hover),
:deep(.register-container .el-select .el-input__wrapper:hover) {
border-color: #ccc !important;
}
:deep(.register-container .el-input__wrapper.is-focus),
:deep(.register-container .el-select .el-input__wrapper.is-focused) {
border-color: #004d40 !important;
box-shadow: 0 0 0 1px rgba(0, 77, 64, 0.2) !important;
}
.register-container .el-form-item__label {
color: #555 !important;
font-weight: 500 !important;
letter-spacing: 0.5px !important;
font-size: 15px !important;
}
.register-container .el-input__inner {
color: #333 !important;
font-size: 16px !important;
}
.register-container .el-button {
height: 45px;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="reset-password-container">
<el-card class="reset-password-card">
<div class="card-header">
<h2>重置密码</h2>
<p>请输入您的验证码和新密码</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
hide-required-asterisk
@submit.prevent="handleSubmit"
autocomplete="off"
>
<el-form-item label="邮箱地址" prop="email">
<el-input
v-model="form.email"
placeholder="请输入您的邮箱地址"
size="large"
autocomplete="email"
disabled
/>
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input
v-model="form.code"
placeholder="请输入6位邮箱验证码"
size="large"
/>
</el-form-item>
<el-form-item label="新密码" prop="password">
<el-input
type="password"
v-model="form.password"
placeholder="请输入您的新密码"
show-password
size="large"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
type="password"
v-model="form.confirmPassword"
placeholder="请再次输入新密码"
show-password
size="large"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="isLoading"
class="submit-button"
size="large"
>
确认重置
</el-button>
</el-form-item>
</el-form>
<div class="card-footer">
<el-link @click="$router.push('/login')">返回登录</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { useRouter, useRoute } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { resetPasswordWithCode } from '@/api/auth';
const router = useRouter();
const route = useRoute();
const formRef = ref<FormInstance>();
const isLoading = ref(false);
const form = reactive({
email: '',
code: '',
password: '',
confirmPassword: '',
});
onMounted(() => {
if (route.query.email) {
form.email = route.query.email as string;
} else {
ElMessage.error('无效的重置链接,请返回重试。');
router.push('/forgot-password');
}
});
const validatePass = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'));
} else if (value !== form.password) {
callback(new Error("两次输入的密码不一致!"));
} else {
callback();
}
};
const rules = reactive<FormRules>({
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
],
code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码必须是6位', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{ validator: validatePass, trigger: 'blur' }
],
});
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (valid) {
isLoading.value = true;
try {
await resetPasswordWithCode({
email: form.email,
code: form.code,
newPassword: form.password,
});
ElMessage.success('密码重置成功!即将跳转到登录页...');
setTimeout(() => router.push('/login'), 2000);
} catch (error) {
ElMessage.error('密码重置失败,请检查验证码是否正确。');
console.error(error);
} finally {
isLoading.value = false;
}
}
});
};
</script>
<style scoped>
.reset-password-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80');
background-size: cover;
background-position: center;
}
.reset-password-card {
width: 480px;
border-radius: 25px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
padding: 20px;
}
.card-header {
text-align: center;
margin-bottom: 2rem;
}
.card-header h2 {
color: #004d40;
margin-bottom: 0.5rem;
font-weight: 600;
}
.card-header p {
color: #607d8b;
font-size: 0.9rem;
}
.el-form-item {
margin-bottom: 1.8rem;
}
.submit-button {
width: 100%;
border-radius: 10px;
background-color: #004d40;
border-color: #004d40;
transition: background-color 0.3s ease;
font-size: 1rem;
padding: 1.2rem 0;
}
.submit-button:hover {
background-color: #00695c;
border-color: #00695c;
}
.card-footer {
margin-top: 1rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="submit-feedback-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>提交新的环境问题反馈</span>
</div>
</template>
<el-form :model="form" :rules="rules" ref="feedbackForm" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入反馈标题"></el-input>
</el-form-item>
<el-form-item label="问题描述" prop="description">
<el-input type="textarea" :rows="4" v-model="form.description" placeholder="请详细描述您发现的问题"></el-input>
</el-form-item>
<el-form-item label="污染类型" prop="pollutionType">
<el-select v-model="form.pollutionType" placeholder="请选择污染类型">
<el-option v-for="(label, key) in pollutionTypeMap" :key="key" :label="label" :value="key"></el-option>
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="severityLevel">
<el-rate v-model="rating" :max="3" :texts="['低', '中', '高']" show-text></el-rate>
</el-form-item>
<el-form-item label="问题地址" prop="location.textAddress">
<el-input v-model="form.location.textAddress" placeholder="请输入问题发生的地址"></el-input>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="经度" prop="location.longitude">
<el-input-number v-model="form.location.longitude" :precision="6" :step="0.000001" placeholder="请输入经度"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="纬度" prop="location.latitude">
<el-input-number v-model="form.location.latitude" :precision="6" :step="0.000001" placeholder="请输入纬度"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="网格X" prop="location.gridX">
<el-input-number v-model="form.location.gridX" :precision="0" :step="1" placeholder="请输入网格X坐标"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="网格Y" prop="location.gridY">
<el-input-number v-model="form.location.gridY" :precision="0" :step="1" placeholder="请输入网格Y坐标"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="照片/视频附件">
<el-upload
ref="upload"
class="upload-demo"
action="#"
:on-preview="handlePreview"
:on-remove="handleRemove"
:on-change="handleChange"
:file-list="fileList"
list-type="picture-card"
:auto-upload="false"
multiple
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="loading">立即提交</el-button>
<el-button @click="resetForm">重置表单</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useFeedbackStore } from '@/store/feedback';
import { PollutionType } from '@/api/types';
import { SeverityLevel } from '@/api/feedback';
import type { FeedbackSubmissionRequest, LocationInfo } from '@/api/feedback';
const feedbackForm = ref<FormInstance>();
const loading = ref(false);
const fileList = ref([]);
const rating = ref(2); // For el-rate which uses a number. 1=LOW, 2=MEDIUM, 3=HIGH
const form = reactive<Omit<FeedbackSubmissionRequest, 'severityLevel'>>({
title: '',
description: '',
pollutionType: PollutionType.PM25,
location: {
latitude: 0,
longitude: 0,
textAddress: '',
gridX: 0,
gridY: 0,
},
});
const pollutionTypeMap: Record<PollutionType, string> = {
[PollutionType.PM25]: 'PM2.5',
[PollutionType.O3]: '臭氧 (O3)',
[PollutionType.NO2]: '二氧化氮 (NO2)',
[PollutionType.SO2]: '二氧化硫 (SO2)',
[PollutionType.OTHER]: '其他',
};
const rules = reactive<FormRules>({
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
description: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
pollutionType: [{ required: true, message: '请选择污染类型', trigger: 'change' }],
'location.textAddress': [{ required: true, message: '请输入问题地址', trigger: 'blur' }],
'location.latitude': [{ required: true, type: 'number', message: '纬度必须为数字' }],
'location.longitude': [{ required: true, type: 'number', message: '经度必须为数字' }],
'location.gridX': [{ required: true, type: 'number', message: '网格X必须为数字' }],
'location.gridY': [{ required: true, type: 'number', message: '网格Y必须为数字' }],
});
const feedbackStore = useFeedbackStore();
const handleRemove = (file, fileListUpdated) => {
fileList.value = fileListUpdated;
};
const handlePreview = (file) => {
console.log(file);
};
const handleChange = (file, fileListUpdated) => {
fileList.value = fileListUpdated;
}
const submitForm = async () => {
if (!feedbackForm.value) return;
await feedbackForm.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
let severity: SeverityLevel;
if (rating.value === 1) {
severity = SeverityLevel.LOW;
} else if (rating.value === 2) {
severity = SeverityLevel.MEDIUM;
} else {
severity = SeverityLevel.HIGH;
}
const submissionData: FeedbackSubmissionRequest = {
...form,
severityLevel: severity,
};
const formData = new FormData();
formData.append('feedback', new Blob([JSON.stringify(submissionData)], { type: 'application/json' }));
fileList.value.forEach(file => {
formData.append('files', file.raw);
});
await feedbackStore.submitFeedback(formData);
ElMessage.success('反馈提交成功!感谢您的贡献。');
resetForm();
} catch (error) {
ElMessage.error('提交失败,请稍后再试。');
} finally {
loading.value = false;
}
}
});
};
const resetForm = () => {
feedbackForm.value?.resetFields();
fileList.value = [];
rating.value = 2;
};
</script>
<style scoped>
.submit-feedback-container {
padding: 20px;
}
.card-header {
font-size: 1.2em;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="supervisor-view">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>待审核反馈</span>
</div>
</template>
<el-table :data="pendingFeedback" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题"></el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
<el-table-column prop="pollutionType" label="污染类型" width="120"></el-table-column>
<el-table-column prop="severityLevel" label="严重等级" width="120"></el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="handleCreateTask(scope.row)">创建任务</el-button>
<el-button size="small" type="danger" @click="handleRejectFeedback(scope.row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- TODO: Add Task Management Section -->
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { Feedback } from '@/api/types';
import { getPendingFeedback, processFeedback } from '@/api/feedback'; // Assuming processFeedback exists
import { formatDateTime } from '@/utils/formatter';
const pendingFeedback = ref<Feedback[]>([]);
const loading = ref(false);
const fetchPendingFeedback = async () => {
loading.value = true;
try {
const response = await getPendingFeedback();
pendingFeedback.value = response.data;
} catch (error) {
ElMessage.error('获取待审核反馈失败');
console.error(error);
} finally {
loading.value = false;
}
};
const handleCreateTask = (feedback: Feedback) => {
// TODO: Implement task creation logic
console.log('Create task from feedback:', feedback.id);
ElMessage.info(`准备为反馈 #${feedback.id} 创建任务`);
};
const handleRejectFeedback = async (feedback: Feedback) => {
try {
await ElMessageBox.confirm(`确定要拒绝这条反馈吗? (ID: ${feedback.id})`, '确认操作', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// This assumes a 'processFeedback' function exists to update feedback status
// to something like 'REJECTED' or 'CLOSED_INVALID'.
// We may need to create or adjust this backend logic.
await processFeedback(feedback.id, { status: 'CLOSED_INVALID' }); // Placeholder status
ElMessage.success('反馈已拒绝');
fetchPendingFeedback(); // Refresh list
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败');
}
}
};
onMounted(() => {
fetchPendingFeedback();
});
</script>
<style scoped>
.supervisor-view {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div>
<h1>系统设置</h1>
<p>这里是系统设置页面</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="task-detail-view" v-if="task">
<el-card class="page-card">
<template #header>
<div class="card-header">
<span>任务详情: {{ task.title }}</span>
<el-tag :type="getStatusTagType(task.status)">{{ formatStatus(task.status) }}</el-tag>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ task.id }}</el-descriptions-item>
<el-descriptions-item label="严重程度">
<el-tag :type="getSeverityTagType(task.severityLevel)">{{ formatSeverity(task.severityLevel) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="污染类型">{{ task.pollutionType || '未指定' }}</el-descriptions-item>
<el-descriptions-item label="网格坐标">X: {{ task.gridX }}, Y: {{ task.gridY }}</el-descriptions-item>
<el-descriptions-item label="分配时间">{{ task.assignedAt ? new Date(task.assignedAt).toLocaleString() : 'N/A' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ task.completedAt ? new Date(task.completedAt).toLocaleString() : 'N/A' }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ task.textAddress || '未提供' }}</el-descriptions-item>
<el-descriptions-item label="任务描述" :span="2">{{ task.description }}</el-descriptions-item>
</el-descriptions>
<div v-if="task.feedback">
<el-divider />
<h3>关联反馈信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="反馈ID">{{ task.feedback.feedbackId }}</el-descriptions-item>
<el-descriptions-item label="提交人">{{ task.feedback.submitterName }}</el-descriptions-item>
<el-descriptions-item label="反馈时间">{{ new Date(task.feedback.createdAt).toLocaleString() }}</el-descriptions-item>
<el-descriptions-item label="反馈标题">{{ task.feedback.title }}</el-descriptions-item>
<el-descriptions-item label="反馈描述" :span="2">{{ task.feedback.description }}</el-descriptions-item>
<el-descriptions-item label="现场图片" :span="2" v-if="task.feedback.imageUrls && task.feedback.imageUrls.length > 0">
<el-image
v-for="url in task.feedback.imageUrls"
:key="url"
:src="url"
:preview-src-list="task.feedback.imageUrls"
style="width: 100px; height: 100px; margin-right: 10px;"
fit="cover"
/>
</el-descriptions-item>
</el-descriptions>
</div>
<el-divider />
<h3>任务历史</h3>
<div v-if="task.history && task.history.length > 0">
<el-timeline>
<el-timeline-item
v-for="item in task.history"
:key="item.id"
:timestamp="new Date(item.changedAt).toLocaleString()"
:type="getHistoryType(item.newStatus)"
>
<strong>{{ formatStatus(item.newStatus) }}</strong>
<p>{{ item.comments }}</p>
</el-timeline-item>
</el-timeline>
</div>
<el-empty v-else description="暂无历史记录" />
<div v-if="task.submissionInfo">
<el-divider />
<h3>任务提交详情</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="提交说明">{{ task.submissionInfo.comments }}</el-descriptions-item>
<el-descriptions-item label="提交附件" v-if="task.submissionInfo.attachments && task.submissionInfo.attachments.length > 0">
<div v-for="att in task.submissionInfo.attachments" :key="att.id">
<el-link :href="att.url" type="primary" target="_blank">{{ att.fileName }}</el-link>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="action-buttons">
<el-button v-if="task.status === 'ASSIGNED'" type="success" @click="acceptTask" :loading="actionLoading">接受任务</el-button>
<el-button v-if="task.status === 'IN_PROGRESS'" type="primary" @click="openSubmitDialog" :loading="actionLoading">提交进度</el-button>
<el-button @click="$router.back()">返回列表</el-button>
</div>
</el-card>
</div>
<div v-else-if="loading">
<el-card class="page-card" v-loading="true" style="min-height: 400px;"></el-card>
</div>
<div v-else>
<el-card class="page-card">
<el-empty description="无法加载任务详情" />
</el-card>
</div>
<SubmitProgressDialog
v-if="task"
v-model:visible="isSubmitDialogVisible"
:task-id="task.id"
@submitted="handleSubmitted"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import apiClient from '@/api';
import { ElMessage, ElMessageBox } from 'element-plus';
import SubmitProgressDialog from '@/components/SubmitProgressDialog.vue';
// --- 类型定义 ---
interface TaskHistoryDTO {
id: number;
changedAt: string;
newStatus: string;
comments: string;
}
interface FeedbackDTO {
feedbackId: number;
eventId: string;
title: string;
description: string;
severityLevel: string;
textAddress: string;
submitterId: number;
submitterName: string;
createdAt: string;
imageUrls: string[];
}
interface AssigneeDTO {
id: number;
name: string;
phone: string;
}
interface AttachmentDTO {
id: number;
fileName: string;
fileType: string;
url: string;
}
interface SubmissionInfoDTO {
comments: string;
attachments: AttachmentDTO[];
}
interface TaskDetail {
id: number;
feedback: FeedbackDTO;
title: string;
description: string;
severityLevel: string;
pollutionType: string;
textAddress: string;
gridX: number;
gridY: number;
status: string;
assignee: AssigneeDTO;
assignedAt: string | null;
completedAt: string | null;
history: TaskHistoryDTO[];
submissionInfo: SubmissionInfoDTO | null;
}
// --- 响应式状态 ---
const route = useRoute();
const router = useRouter();
const taskId = ref(route.params.id as string);
const task = ref<TaskDetail | null>(null);
const loading = ref(false);
const actionLoading = ref(false);
const isSubmitDialogVisible = ref(false);
// --- API 调用 ---
const fetchTaskDetails = async () => {
loading.value = true;
try {
const response = await apiClient.get(`/worker/${taskId.value}`);
task.value = response as TaskDetail;
} catch (error) {
console.error(`获取任务详情失败 (ID: ${taskId.value}):`, error);
ElMessage.error('获取任务详情失败');
} finally {
loading.value = false;
}
};
const acceptTask = async () => {
actionLoading.value = true;
try {
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
});
await apiClient.post(`/worker/${taskId.value}/accept`);
ElMessage.success('任务已接受');
fetchTaskDetails(); // Refresh details
} catch (error) {
if (error !== 'cancel') {
console.error('接受任务失败:', error);
ElMessage.error('接受任务失败');
}
} finally {
actionLoading.value = false;
}
};
const openSubmitDialog = () => {
isSubmitDialogVisible.value = true;
};
const handleSubmitted = () => {
fetchTaskDetails();
};
// --- 辅助函数 ---
const formatStatus = (status: string) => {
const map: Record<string, string> = {
'ASSIGNED': '待接受',
'IN_PROGRESS': '进行中',
'SUBMITTED': '已提交',
'COMPLETED': '已完成',
'REJECTED': '已拒绝',
};
return map[status] || status;
};
const getStatusTagType = (status: string): 'primary' | 'warning' | 'info' | 'success' | 'danger' => {
const map: Record<string, 'primary' | 'warning' | 'info' | 'success' | 'danger'> = {
'ASSIGNED': 'primary',
'IN_PROGRESS': 'warning',
'SUBMITTED': 'info',
'COMPLETED': 'success',
'REJECTED': 'danger',
};
return map[status] || 'info';
};
const formatSeverity = (severity: string) => {
const map: Record<string, string> = {
'LOW': '低',
'MEDIUM': '中',
'HIGH': '高',
};
return map[severity] || severity;
};
const getSeverityTagType = (severity: string): 'info' | 'warning' | 'danger' => {
const map: Record<string, 'info' | 'warning' | 'danger'> = {
'LOW': 'info',
'MEDIUM': 'warning',
'HIGH': 'danger',
};
return map[severity] || 'info';
};
const getHistoryType = (status: string): 'primary' | 'success' | 'info' => {
switch (status) {
case 'ASSIGNED':
case 'IN_PROGRESS':
return 'primary';
case 'COMPLETED':
return 'success';
default:
return 'info';
}
};
onMounted(() => {
fetchTaskDetails();
});
</script>
<style scoped>
.task-detail-view {
padding: 20px;
}
.page-card {
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: 600;
}
.action-buttons {
margin-top: 20px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div>
<h1>任务管理</h1>
<p>这里是任务管理页面</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,437 @@
<template>
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>人员管理</span>
</div>
</template>
<div class="table-header">
<el-input v-model="searchName" placeholder="按姓名搜索" clearable @clear="fetchUsers" @keyup.enter="fetchUsers" style="width: 200px;"></el-input>
<el-select v-model="searchRole" placeholder="按角色筛选" clearable @change="fetchUsers" style="width: 150px;">
<el-option label="管理员" value="ADMIN"></el-option>
<el-option label="决策者" value="DECISION_MAKER"></el-option>
<el-option label="主管" value="SUPERVISOR"></el-option>
<el-option label="网格员" value="GRID_WORKER"></el-option>
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
</el-select>
<el-button type="primary" @click="fetchUsers" :icon="Search">搜索</el-button>
</div>
<el-table :data="users" v-loading="loading" style="width: 100%" border stripe table-layout="auto" class="user-table compact-table">
<el-table-column prop="id" label="ID" width="60" align="center"></el-table-column>
<el-table-column prop="name" label="姓名" width="120" align="center"></el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180"></el-table-column>
<el-table-column prop="phone" label="手机号" width="120" align="center"></el-table-column>
<el-table-column prop="gender" label="性别" width="70" align="center">
<template #default="scope">
{{ formatGender(scope.row.gender) }}
</template>
</el-table-column>
<el-table-column prop="role" label="角色" width="150" align="center">
<template #default="scope">
<el-tag :type="roleTagType(scope.row.role)" size="small">{{ formatRole(scope.row.role) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small" disable-transitions>{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-popconfirm title="确定要删除这位用户吗?" @confirm="handleDelete(scope.row.id)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-sizes="[10, 20, 50, 100]"
v-model:page-size="pagination.size"
v-model:current-page="pagination.page"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
style="margin-top: 20px; justify-content: flex-end;"
></el-pagination>
<!-- Edit User Dialog -->
<el-dialog v-model="editDialogVisible" title="编辑用户信息" width="700px" :show-close="false" class="edit-dialog ultimate-dialog">
<template #header="{ close, titleId, titleClass }">
<div class="ultimate-dialog-header">
<h4 :id="titleId" :class="titleClass">
<el-icon class="header-icon"><EditPen /></el-icon>
编辑用户信息
</h4>
<el-button circle :icon="Close" type="danger" class="header-close-btn" @click="close"></el-button>
</div>
</template>
<el-form :model="editForm" ref="editFormRef" label-width="90px" label-position="top">
<div class="form-section">
<h3 class="section-title">身份凭证</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item>
<template #label>
<div class="form-label-icon">
<el-icon><User /></el-icon>
<span>用户ID</span>
</div>
</template>
<el-input v-model="editForm.id" disabled class="readonly-input"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item>
<template #label>
<div class="form-label-icon">
<el-icon><Avatar /></el-icon>
<span>姓名</span>
</div>
</template>
<el-input v-model="editForm.name" disabled class="readonly-input"></el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-section">
<h3 class="section-title">详细信息</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item prop="phone">
<template #label>
<div class="form-label-icon">
<el-icon><Phone /></el-icon>
<span>手机号</span>
</div>
</template>
<el-input v-model="editForm.phone" placeholder="请输入手机号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="gender">
<template #label>
<div class="form-label-icon">
<el-icon><Male /></el-icon>
<span>性别</span>
</div>
</template>
<el-radio-group v-model="editForm.gender">
<el-radio label="MALE"></el-radio>
<el-radio label="FEMALE"></el-radio>
<el-radio label="OTHER">其他</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item prop="role">
<template #label>
<div class="form-label-icon">
<el-icon><Key /></el-icon>
<span>角色</span>
</div>
</template>
<el-select v-model="editForm.role" placeholder="选择角色" style="width:100%;">
<el-option label="管理员" value="ADMIN"></el-option>
<el-option label="决策者" value="DECISION_MAKER"></el-option>
<el-option label="主管" value="SUPERVISOR"></el-option>
<el-option label="网格员" value="GRID_WORKER"></el-option>
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="status">
<template #label>
<div class="form-label-icon">
<el-icon><Switch /></el-icon>
<span>状态</span>
</div>
</template>
<el-select v-model="editForm.status" placeholder="选择状态" style="width:100%;">
<el-option label="ACTIVE" value="ACTIVE"></el-option>
<el-option label="INACTIVE" value="INACTIVE"></el-option>
<el-option label="SUSPENDED" value="SUSPENDED"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false" plain> </el-button>
<el-button type="primary" @click="submitEdit" :loading="isSaving" :icon="Check"> </el-button>
</span>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Search, User, Avatar, Phone, Male, Key, Switch, Close, Check, EditPen } from '@element-plus/icons-vue';
import { getUsers, updateUser, deleteUser } from '@/api/personnel';
import type { UserAccount, UserUpdateRequest } from '@/api/types';
const users = ref<UserAccount[]>([]);
const loading = ref(true);
const total = ref(0);
const searchName = ref('');
const searchRole = ref('');
const pagination = reactive({
page: 1,
size: 10,
});
const editDialogVisible = ref(false);
const isSaving = ref(false);
const editFormRef = ref();
const editForm = reactive<Partial<UserAccount>>({});
const roleMap: Record<string, string> = {
ADMIN: '管理员',
DECISION_MAKER: '决策者',
SUPERVISOR: '主管',
GRID_WORKER: '网格员',
PUBLIC_SUPERVISOR: '公众监督员'
};
const formatRole = (role: string) => roleMap[role] || role;
const formatGender = (gender: 'MALE' | 'FEMALE' | 'OTHER') => {
if (gender === 'MALE') return '男';
if (gender === 'FEMALE') return '女';
return '未知';
}
const roleTagType = (role: string) => {
switch (role) {
case 'ADMIN': return 'danger';
case 'DECISION_MAKER': return 'warning';
case 'SUPERVISOR': return 'primary';
default: return 'info';
}
};
const statusTagType = (status: string) => {
if (status === 'ACTIVE') return 'success';
if (status === 'INACTIVE') return 'info';
if (status === 'SUSPENDED') return 'warning';
return '';
};
const fetchUsers = async () => {
loading.value = true;
try {
const params = {
page: pagination.page - 1,
size: pagination.size,
name: searchName.value || undefined,
role: searchRole.value || undefined,
};
const response = await getUsers(params);
users.value = response.content;
total.value = response.totalElements;
} catch (error) {
console.error("获取用户列表时出错:", error);
ElMessage.error('获取用户列表失败,请检查网络或联系管理员。');
} finally {
loading.value = false;
}
};
const handleEdit = (user: UserAccount) => {
Object.assign(editForm, user);
editDialogVisible.value = true;
};
const submitEdit = async () => {
if (!editForm.id) return;
isSaving.value = true;
const updateData: UserUpdateRequest = {
phone: editForm.phone,
gender: editForm.gender as 'MALE' | 'FEMALE' | 'OTHER',
role: editForm.role,
status: editForm.status,
};
try {
await updateUser(editForm.id, updateData);
ElMessage.success('用户信息更新成功');
editDialogVisible.value = false;
await fetchUsers();
} catch (error) {
console.error(`更新用户 #${editForm.id} 失败:`, error);
ElMessage.error('更新失败,请重试。');
} finally {
isSaving.value = false;
}
};
const handleDelete = async (userId: number) => {
try {
await deleteUser(userId);
ElMessage.success('删除成功');
await fetchUsers();
} catch (error) {
console.error(`删除用户 #${userId} 失败:`, error);
ElMessage.error('删除失败,请重试。');
}
};
const handleSizeChange = (size: number) => {
pagination.size = size;
fetchUsers();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
fetchUsers();
};
onMounted(() => {
fetchUsers();
});
</script>
<style scoped>
.box-card {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 20px;
font-weight: bold;
}
.table-header {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.user-table {
font-size: 14px;
}
.compact-table :deep(.el-table__cell) {
padding: 6px 0;
}
.user-table .el-button {
margin: 0 4px;
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
:deep(.el-table__row .el-button) {
visibility: hidden;
}
:deep(.el-table__row:hover .el-button) {
visibility: visible;
}
/* Ultimate Dialog Styles */
.ultimate-dialog .el-dialog__header {
display: none; /* Hide original header */
}
.ultimate-dialog {
--el-dialog-padding-primary: 0;
background: linear-gradient(to top right, #e0f2f1, #ffffff);
}
.ultimate-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ultimate-dialog-header h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 1.3rem;
}
.header-close-btn {
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: var(--el-color-danger-light-8);
--el-button-hover-border-color: var(--el-color-danger-light-8);
}
.ultimate-dialog .el-dialog__body {
padding: 24px 30px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%);
}
.form-section {
background-color: rgba(255, 255, 255, 0.7);
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.05);
margin-bottom: 25px;
border: 1px solid rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #495057;
margin-top: 0;
margin-bottom: 24px;
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.form-label-icon {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #343a40;
}
.readonly-input {
--el-input-disabled-bg-color: #eef1f6;
--el-input-disabled-text-color: #303133;
--el-input-disabled-border-color: #dcdfe6;
}
.dialog-footer {
text-align: right;
padding: 10px 30px 20px;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="profile-container">
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>个人信息</span>
</div>
</template>
<div v-if="user" class="profile-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID" label-align="right" align="center" label-class-name="my-label">
{{ user.id }}
</el-descriptions-item>
<el-descriptions-item label="姓名" label-align="right" align="center">
{{ user.name }}
</el-descriptions-item>
<el-descriptions-item label="邮箱" label-align="right" align="center">
{{ user.email }}
</el-descriptions-item>
<el-descriptions-item label="电话" label-align="right" align="center">
{{ user.phone || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="角色" label-align="right" align="center">
<el-tag>{{ user.role }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态" label-align="right" align="center">
<el-tag :type="user.status === 'ACTIVE' ? 'success' : 'danger'">{{ user.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属区域" label-align="right" align="center" :span="2">
{{ user.region || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="技能" label-align="right" align="center" :span="2">
<template v-if="user.skills && user.skills.length">
<el-tag v-for="skill in user.skills" :key="skill" type="info" style="margin-right: 8px;">
{{ skill }}
</el-tag>
</template>
<span v-else>N/A</span>
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else>
<p>正在加载用户信息...</p>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { storeToRefs } from 'pinia';
// 从 Pinia store 中获取用户信息
const authStore = useAuthStore();
// 使用 storeToRefs 来保持响应性
const { user } = storeToRefs(authStore);
</script>
<style scoped>
.profile-container {
padding: 20px;
}
.profile-card {
max-width: 800px;
margin: 0 auto;
}
.card-header {
font-size: 20px;
font-weight: bold;
}
.profile-content {
padding: 20px;
}
.my-label {
background: var(--el-color-success-light-9) !important;
}
</style>

View File

@@ -0,0 +1,337 @@
<template>
<div class="grid-management-container">
<!-- Page Header -->
<div class="page-header">
<h2>我的网格地图</h2>
<div class="header-actions">
<el-button type="primary" @click="refreshData" :loading="loading || isLoadingWorkers" icon="Refresh">
刷新数据
</el-button>
</div>
</div>
<!-- Main Content -->
<div class="main-content-grid">
<!-- Grid Map Area -->
<div class="map-area" v-loading="loading">
<div v-if="error" class="error-message">
<el-alert title="地图数据加载失败" :description="error" type="error" show-icon :closable="false" />
<el-button @click="fetchAllGrids" type="primary" class="retry-button">重试</el-button>
</div>
<svg v-else-if="displayGrids.length" :width="svgDimensions.width" :height="svgDimensions.height" class="grid-svg">
<defs>
<pattern id="grid-pattern-worker" :width="CELL_SIZE" :height="CELL_SIZE" patternUnits="userSpaceOnUse">
<path :d="`M ${CELL_SIZE} 0 L 0 0 0 ${CELL_SIZE}`" fill="none" stroke="#e9e9e9" stroke-width="0.5" />
</pattern>
</defs>
<rect :width="svgDimensions.width" :height="svgDimensions.height" fill="url(#grid-pattern-worker)" />
<g v-for="grid in displayGrids" :key="grid.id">
<rect
:x="grid.displayX * CELL_SIZE"
:y="grid.displayY * CELL_SIZE"
:width="CELL_SIZE"
:height="CELL_SIZE"
:fill="getGridFillColor(grid)"
@click="selectGrid(grid)"
:class="{ 'selected': selectedGrid && selectedGrid.id === grid.id }"
class="grid-cell"
/>
<!-- Border Rendering -->
<line v-if="getBorder(grid, 'top')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="grid.displayY * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'right')" :x1="(grid.displayX + 1) * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'bottom')" :x1="grid.displayX * CELL_SIZE" :y1="(grid.displayY + 1) * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
<line v-if="getBorder(grid, 'left')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="grid.displayX * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
</g>
</svg>
<el-empty v-else description="没有可显示的网格数据" />
</div>
<!-- Details Sidebar -->
<div class="sidebar-area">
<el-card shadow="never" class="details-card">
<template #header>
<div class="card-header">
<span>网格详情</span>
</div>
</template>
<div v-if="selectedGrid" class="details-content">
<p><strong>网格ID:</strong> {{ selectedGrid.id }}</p>
<p><strong>城市:</strong> <el-tag :color="getCityColor(selectedGrid.cityName)" effect="light">{{ selectedGrid.cityName }}</el-tag></p>
<p><strong>区县:</strong> {{ selectedGrid.districtName || 'N/A' }}</p>
<p><strong>原始坐标:</strong> ({{ selectedGrid.gridX }}, {{ selectedGrid.gridY }})</p>
<p><strong>是否为障碍物:</strong> <el-tag :type="selectedGrid.isObstacle ? 'danger' : 'success'">{{ selectedGrid.isObstacle ? '是' : '否' }}</el-tag></p>
<p><strong>描述:</strong> {{ selectedGrid.description || '无' }}</p>
<el-divider />
<p><strong>负责人:</strong></p>
<div v-if="selectedGridWorker">
<el-tag effect="plain" type="info">
<el-icon><User /></el-icon>
{{ selectedGridWorker.name }} ({{ selectedGridWorker.phone }})
</el-tag>
</div>
<div v-else>
<el-tag type="warning">未分配</el-tag>
</div>
</div>
<el-empty v-else description="请在左侧地图上选择一个网格" />
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ElMessage } from 'element-plus';
import type { Grid, UserAccount } from '@/api/types';
import { getGrids } from '@/api/dashboard';
import { getAllGridWorkers } from '@/api/personnel';
import { User } from '@element-plus/icons-vue';
const CELL_SIZE = 25;
const PADDING_CELLS = 2;
// --- State ---
const gridData = ref<Grid[]>([]);
const allWorkers = ref<UserAccount[]>([]);
const loading = ref(false);
const isLoadingWorkers = ref(false);
const error = ref<string | null>(null);
const selectedGrid = ref<DisplayGrid | null>(null);
// --- Data Fetching & Refresh ---
const fetchAllGrids = async () => {
loading.value = true;
error.value = null;
selectedGrid.value = null;
try {
const response = await getGrids();
gridData.value = Array.isArray(response) ? response : (response as any).content || [];
} catch (e: any) {
error.value = e.message || '获取网格数据时发生未知错误';
ElMessage.error(error.value);
} finally {
loading.value = false;
}
};
const fetchAllWorkers = async () => {
isLoadingWorkers.value = true;
try {
allWorkers.value = await getAllGridWorkers();
} catch (e: any) {
ElMessage.error('获取网格员列表失败: ' + e.message);
} finally {
isLoadingWorkers.value = false;
}
};
const refreshData = async () => {
await Promise.all([fetchAllGrids(), fetchAllWorkers()]);
ElMessage.success('数据已刷新');
}
onMounted(() => {
refreshData();
});
// --- Computed Properties ---
const workersByCoord = computed<Map<string, UserAccount>>(() => {
const map = new Map<string, UserAccount>();
allWorkers.value.forEach(worker => {
if (worker.gridX !== null && worker.gridY !== null) {
map.set(`${worker.gridX},${worker.gridY}`, worker);
}
});
return map;
});
const selectedGridWorker = computed<UserAccount | undefined>(() => {
if (!selectedGrid.value || !allWorkers.value.length) {
return undefined;
}
return allWorkers.value.find(
worker => worker.gridX === selectedGrid.value?.gridX && worker.gridY === selectedGrid.value?.gridY
);
});
// --- City Color Logic ---
const cityColors = ref<Map<string, string>>(new Map());
const getCityColor = (cityName: string): string => {
if (!cityColors.value.has(cityName)) {
let hash = 0;
for (let i = 0; i < cityName.length; i++) {
hash = cityName.charCodeAt(i) + ((hash << 5) - hash);
}
const color = `hsl(${hash % 360}, 80%, 85%)`;
cityColors.value.set(cityName, color);
}
return cityColors.value.get(cityName)!;
};
const getGridFillColor = (grid: Grid): string => {
if (grid.isObstacle) {
return '#000000';
}
if (workersByCoord.value.has(`${grid.gridX},${grid.gridY}`)) {
return '#FF0000';
}
return getCityColor(grid.cityName);
};
// --- Display Logic ---
interface DisplayGrid extends Grid {
displayX: number;
displayY: number;
}
const displayGrids = computed<DisplayGrid[]>(() => {
if (!gridData.value.length) return [];
// Determine the top-left corner of the entire map to normalize coordinates
const minX = Math.min(...gridData.value.map(g => g.gridX));
const minY = Math.min(...gridData.value.map(g => g.gridY));
// Render all grids based on their absolute positions, normalized to start at (0,0)
return gridData.value.map(grid => ({
...grid,
displayX: grid.gridX - minX,
displayY: grid.gridY - minY,
}));
});
const gridMap = computed(() => {
const map = new Map<string, DisplayGrid>();
displayGrids.value.forEach(grid => {
map.set(`${grid.displayX},${grid.displayY}`, grid);
});
return map;
});
const svgDimensions = computed(() => {
if (!displayGrids.value.length) return { width: 0, height: 0 };
const maxX = Math.max(...displayGrids.value.map(g => g.displayX));
const maxY = Math.max(...displayGrids.value.map(g => g.displayY));
return {
width: (maxX + 1) * CELL_SIZE,
height: (maxY + 1) * CELL_SIZE,
};
});
const getBorder = (grid: DisplayGrid, side: 'top' | 'right' | 'bottom' | 'left'): boolean => {
const { displayX, displayY, cityName } = grid;
let neighbor: DisplayGrid | undefined;
switch (side) {
case 'top':
neighbor = gridMap.value.get(`${displayX},${displayY - 1}`);
break;
case 'right':
neighbor = gridMap.value.get(`${displayX + 1},${displayY}`);
break;
case 'bottom':
neighbor = gridMap.value.get(`${displayX},${displayY + 1}`);
break;
case 'left':
neighbor = gridMap.value.get(`${displayX - 1},${displayY}`);
break;
}
return !neighbor || neighbor.cityName !== cityName;
};
const selectGrid = (grid: DisplayGrid) => {
selectedGrid.value = grid;
};
</script>
<style scoped>
.grid-management-container {
padding: 20px;
background-color: #f7f8fa;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-shrink: 0;
}
.page-header h2 {
margin: 0;
font-size: 24px;
}
.main-content-grid {
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
}
.map-area {
flex: 3;
background-color: #fff;
border-radius: 8px;
padding: 10px;
overflow: auto;
border: 1px solid #e9e9e9;
}
.sidebar-area {
flex: 1;
min-width: 300px;
}
.details-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.grid-svg {
display: block;
}
.grid-cell {
stroke: #fff;
stroke-width: 1;
transition: filter 0.2s ease-in-out;
}
.grid-cell:hover {
filter: brightness(0.9);
}
.grid-cell.selected {
stroke: #ff4d4f;
stroke-width: 2;
}
.border-line {
stroke: #333;
stroke-width: 1.5;
}
.error-message {
padding: 20px;
}
.retry-button {
margin-top: 15px;
}
.details-content p {
margin: 0 0 12px;
color: #606266;
}
.details-content p strong {
color: #303133;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="worker-task-view">
<el-card class="page-card">
<template #header>
<div class="card-header">
<span>我的任务</span>
</div>
</template>
<!-- 筛选区域 -->
<div class="filter-container">
<el-radio-group v-model="selectedStatus" @change="fetchTasks">
<el-radio-button :label="null">全部</el-radio-button>
<el-radio-button label="ASSIGNED">已分配</el-radio-button>
<el-radio-button label="IN_PROGRESS">进行中</el-radio-button>
<el-radio-button label="SUBMITTED">已提交</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
</el-radio-group>
</div>
<!-- 任务列表 -->
<el-table :data="tasks" v-loading="loading" stripe class="task-table">
<el-table-column prop="id" label="任务ID" width="80" />
<el-table-column prop="title" label="任务标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">{{ formatStatus(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="textAddress" label="地址" min-width="250" show-overflow-tooltip />
<el-table-column prop="severity" label="严重程度" width="100">
<template #default="{ row }">
<el-tag :type="getSeverityTagType(row.severity)">{{ formatSeverity(row.severity) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="分配时间" width="180">
<template #default="{ row }">
<span>{{ new Date(row.createdAt).toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewTaskDetails(row.id)">查看详情</el-button>
<el-button v-if="row.status === 'ASSIGNED'" type="success" link size="small" @click="acceptTask(row.id)">接受</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import apiClient from '@/api';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { Pageable } from '@/api/types';
// --- 类型定义 ---
interface TaskSummary {
id: number;
title: string;
description: string;
status: string;
assigneeName: string | null;
createdAt: string;
textAddress: string;
imageUrl: string;
severity: string;
}
// --- 响应式状态 ---
const tasks = ref<TaskSummary[]>([]);
const loading = ref(false);
const selectedStatus = ref<string | null>(null);
const total = ref(0);
const pagination = reactive({
page: 1,
size: 10,
});
// --- API 调用 ---
const fetchTasks = async () => {
loading.value = true;
try {
const params: any = {
page: pagination.page - 1,
size: pagination.size,
};
if (selectedStatus.value) {
params.status = selectedStatus.value;
}
// 注意这里的API返回的是Page<T>但Controller返回的是List<T>
// 需要调整Controller或这里的处理方式。暂时假设Controller返回List
const response = await apiClient.get<TaskSummary[]>('/worker', { params });
tasks.value = response;
// @ts-ignore
// total.value = response.totalElements || response.length; // 假设 totalElements
} catch (error) {
console.error('获取任务列表失败:', error);
ElMessage.error('获取任务列表失败');
tasks.value = [];
} finally {
loading.value = false;
}
};
const acceptTask = async (taskId: number) => {
try {
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
});
await apiClient.post(`/worker/${taskId}/accept`);
ElMessage.success('任务已接受');
fetchTasks(); // 刷新列表
} catch (error) {
if (error !== 'cancel') {
console.error('接受任务失败:', error);
ElMessage.error('接受任务失败');
}
}
};
// --- 辅助函数 ---
const formatStatus = (status: string) => {
const map: Record<string, string> = {
'ASSIGNED': '已分配',
'IN_PROGRESS': '进行中',
'SUBMITTED': '已提交',
'COMPLETED': '已完成',
'REJECTED': '已拒绝',
};
return map[status] || status;
};
const getStatusTagType = (status: string) => {
const map: Record<string, string> = {
'ASSIGNED': 'primary',
'IN_PROGRESS': 'warning',
'SUBMITTED': 'info',
'COMPLETED': 'success',
'REJECTED': 'danger',
};
return map[status] || 'info';
};
const formatSeverity = (severity: string) => {
const map: Record<string, string> = {
'LOW': '低',
'MEDIUM': '中',
'HIGH': '高',
};
return map[severity] || severity;
};
const getSeverityTagType = (severity: string) => {
const map: Record<string, string> = {
'LOW': 'info',
'MEDIUM': 'warning',
'HIGH': 'danger',
};
return map[severity] || 'info';
};
// --- 事件处理 ---
const viewTaskDetails = (taskId: number) => {
// TODO: 实现跳转到任务详情页或打开详情对话框
ElMessage.info(`查看任务详情: ${taskId}`);
};
const handleSizeChange = (size: number) => {
pagination.size = size;
fetchTasks();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
fetchTasks();
};
// --- 生命周期钩子 ---
onMounted(() => {
fetchTasks();
});
</script>
<style scoped>
.worker-task-view {
padding: 20px;
}
.page-card {
border-radius: 8px;
}
.card-header {
font-size: 18px;
font-weight: 600;
}
.filter-container {
margin-bottom: 20px;
}
.task-table {
width: 100%;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@@ -0,0 +1,40 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
// 字符串简写写法
// '/foo': 'http://localhost:4567',
// 选项写法
'/api': {
target: 'http://localhost:8080', // 确保这是您的后端服务地址
changeOrigin: true,
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
console.log('代理错误:', err);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('发送代理请求到目标:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, res) => {
console.log('收到来自目标的代理响应:', proxyRes.statusCode, req.url);
});
}
},
}
}
})