首頁與報表管理組件

This commit is contained in:
koko 2025-10-21 17:49:53 +08:00
parent cc25dce91c
commit 0e767795be
27 changed files with 2401 additions and 299 deletions

860
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.12.2",
"dayjs": "^1.11.18",
"echarts": "^6.0.0",
"element-plus": "^2.11.4",
"leaflet": "^1.9.4",
@ -19,6 +20,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.2",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.7"
@ -70,6 +72,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz",
"integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==",
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
@ -604,6 +613,316 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@ -1324,6 +1643,13 @@
"node": ">=8"
}
},
"node_modules/buffer-builder": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"dev": true,
"license": "MIT/X11"
},
"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",
@ -1362,6 +1688,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1435,6 +1768,20 @@
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1769,6 +2116,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -1814,6 +2171,13 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -1961,6 +2325,35 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -2045,6 +2438,14 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -2260,6 +2661,419 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz",
"integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
"colorjs.io": "^0.5.0",
"immutable": "^5.0.2",
"rxjs": "^7.4.0",
"supports-color": "^8.1.1",
"sync-child-process": "^1.0.2",
"varint": "^6.0.0"
},
"bin": {
"sass": "dist/bin/sass.js"
},
"engines": {
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-all-unknown": "1.93.2",
"sass-embedded-android-arm": "1.93.2",
"sass-embedded-android-arm64": "1.93.2",
"sass-embedded-android-riscv64": "1.93.2",
"sass-embedded-android-x64": "1.93.2",
"sass-embedded-darwin-arm64": "1.93.2",
"sass-embedded-darwin-x64": "1.93.2",
"sass-embedded-linux-arm": "1.93.2",
"sass-embedded-linux-arm64": "1.93.2",
"sass-embedded-linux-musl-arm": "1.93.2",
"sass-embedded-linux-musl-arm64": "1.93.2",
"sass-embedded-linux-musl-riscv64": "1.93.2",
"sass-embedded-linux-musl-x64": "1.93.2",
"sass-embedded-linux-riscv64": "1.93.2",
"sass-embedded-linux-x64": "1.93.2",
"sass-embedded-unknown-all": "1.93.2",
"sass-embedded-win32-arm64": "1.93.2",
"sass-embedded-win32-x64": "1.93.2"
}
},
"node_modules/sass-embedded-all-unknown": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz",
"integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==",
"cpu": [
"!arm",
"!arm64",
"!riscv64",
"!x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"sass": "1.93.2"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz",
"integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz",
"integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz",
"integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz",
"integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz",
"integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz",
"integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz",
"integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz",
"integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz",
"integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz",
"integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz",
"integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz",
"integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz",
"integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz",
"integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-unknown-all": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz",
"integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!android",
"!darwin",
"!linux",
"!win32"
],
"dependencies": {
"sass": "1.93.2"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz",
"integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz",
"integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
@ -2316,6 +3130,45 @@
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/sync-child-process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz",
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"sync-message-port": "^1.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/sync-message-port": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz",
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2503,6 +3356,13 @@
}
}
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.12.2",
"dayjs": "^1.11.18",
"echarts": "^6.0.0",
"element-plus": "^2.11.4",
"leaflet": "^1.9.4",
@ -20,6 +21,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.2",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.7"

View File

@ -4,7 +4,6 @@ import { useRouter, useRoute } from "vue-router";
import { ElConfigProvider } from "element-plus";
import Navbar from "./components/Navbar.vue";
import Sidebar from "./components/Sidebar.vue";
import Breadcrumb from "./components/Breadcrumb.vue";
const router = useRouter();
const route = useRoute();
@ -56,7 +55,6 @@ onUnmounted(() => {
<Navbar v-model:isCollapse="isCollapse" />
</el-header>
<el-main>
<!-- <Breadcrumb /> -->
<router-view />
</el-main>
</el-container>

20
src/components.d.ts vendored
View File

@ -8,42 +8,36 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Breadcrumb: typeof import('./components/Breadcrumb.vue')['default']
ComboBarLineChart: typeof import('./components/Chart/ComboBarLineChart.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElList: typeof import('element-plus/es')['ElList']
ElListItem: typeof import('element-plus/es')['ElListItem']
ElListItemContent: typeof import('element-plus/es')['ElListItemContent']
ElInput: typeof import('element-plus/es')['ElInput']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
InfoList: typeof import('./components/InfoList.vue')['default']
LineChart: typeof import('./components/Chart/LineChart.vue')['default']
Navbar: typeof import('./components/Navbar.vue')['default']
PieChart: typeof import('./components/Chart/PieChart.vue')['default']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Sidebar: typeof import('./components/Sidebar.vue')['default']
}
}

View File

@ -1,19 +0,0 @@
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(item, idx) in breadcrumbs" :key="item.path || idx" :to="item.path && idx !== breadcrumbs.length - 1 ? { path: item.path } : undefined">
<span v-if="!item.path || idx === breadcrumbs.length - 1">{{ item.meta && item.meta.title ? item.meta.title : item.name }}</span>
<span v-else>{{ item.meta && item.meta.title ? item.meta.title : item.name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const breadcrumbs = computed(() => {
// route.matched
return route.matched.filter(r => r.meta && r.meta.title);
});
</script>

View File

@ -1,103 +1,177 @@
<template>
<div v-if="showToolbox" style="text-align: right; margin: 3px 8px">
<el-button size="small" :icon="Download" @click="exportChart()">
下載圖表
</el-button>
<el-button size="small" :icon="FullScreen" @click="openChartInNewTab">
全螢幕瀏覽
</el-button>
</div>
<div :id="chartId" style="width: 100%; min-height: 250px"></div>
</template>
<script setup>
import { onMounted, onUnmounted, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
import { onMounted, onUnmounted, nextTick, computed } from "vue";
import { Download, FullScreen } from "@element-plus/icons-vue";
import * as echarts from "echarts";
const props = defineProps({
data: {
type: Array,
required: true
type: Array,
required: true,
},
series: {
type: Array,
required: true,
},
xField: {
type: String,
default: "date",
},
title: {
type: String,
default: ''
}
})
const chartId = `comboChart-${Math.random().toString(36).slice(2)}`
let chartInstance = null
const years = computed(() => props.data.map(item => item.year))
//
const plantNames = computed(() => {
const set = new Set();
props.data.forEach(item => {
item.data.forEach(d => set.add(d.name));
});
return Array.from(set);
default: "",
},
yLeftUnit: {
type: String,
default: "MW",
},
yRightUnit: {
type: String,
default: "",
},
showToolbox: {
type: Boolean,
default: false,
},
});
// series
const chartId = `comboChart-${Math.random().toString(36).slice(2)}`;
let chartInstance = null;
const xAxisData = computed(() => props.data.map((item) => item[props.xField]));
function getSeries() {
const barSeries = plantNames.value.map(name => ({
name,
type: 'bar',
data: props.data.map(item => {
const found = item.data.find(d => d.name === name);
return found ? found.value : 0;
}),
barMaxWidth: 32
}));
//
const totalSeries = {
name: '總發電量',
type: 'line',
data: props.data.map(item => item.data.reduce((sum, d) => sum + d.value, 0)),
symbol: 'circle',
symbolSize: 10,
lineStyle: { width: 3, color: '#409EFF' },
itemStyle: { color: '#409EFF', borderColor: '#fff', borderWidth: 2 }
};
return [...barSeries, totalSeries];
return props.series.map((s) => {
const base = {
name: s.name,
type: s.type,
data: props.data.map((item) => item[s.field]),
barMaxWidth: 32,
yAxisIndex: s.yAxisIndex || 0,
};
if (s.type === "bar") {
base.itemStyle = { color: s.color || "#91cc75" };
} else if (s.type === "line") {
base.symbol = "circle";
base.symbolSize = 10;
base.itemStyle = {
color: s.color || "#409EFF",
borderColor: "#fff",
borderWidth: 2,
};
}
return base;
});
}
//
function exportChart(options = {}) {
if (!chartInstance) return;
const url = chartInstance.getDataURL({
type: options.type || "png",
pixelRatio: options.pixelRatio || 2,
backgroundColor: options.backgroundColor || "#fff",
name: options.name || props.title || "chart",
...options,
});
//
const a = document.createElement("a");
a.href = url;
a.download =
(options.name || props.title || "chart") + "." + (options.type || "png");
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
//
function openChartInNewTab() {
if (!chartInstance) return;
const url = chartInstance.getDataURL({
type: "png",
pixelRatio: 2,
backgroundColor: "#fff",
name: props.title || "chart",
});
const win = window.open();
if (win) {
win.document.write(
`<title>${
props.title || "圖表"
}</title><img src='${url}' style='display:block;margin:auto;max-width:100vw;max-height:100vh;'/>`
);
win.document.close();
}
}
onMounted(() => {
nextTick(() => {
const chartDom = document.getElementById(chartId)
const chartDom = document.getElementById(chartId);
if (chartDom) {
chartInstance = echarts.init(chartDom)
chartInstance = echarts.init(chartDom);
chartInstance.setOption({
title: props.title ? {
text: props.title,
left: 'center',
textStyle: { fontSize: 16, color: '#333' }
} : undefined,
title: props.title
? {
text: props.title,
left: "center",
textStyle: { fontSize: 16, color: "#333" },
}
: undefined,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
trigger: "axis",
axisPointer: { type: "shadow" },
},
legend: {
bottom: 0
bottom: 0,
},
grid: {
left: 10,
right: 10,
top: 30,
bottom: 30,
containLabel: true
containLabel: true,
},
xAxis: {
type: 'category',
data: years.value
type: "category",
data: xAxisData.value,
},
yAxis: {
type: 'value',
minInterval: 1,
name: 'MW',
},
series: getSeries()
})
yAxis: [
{
type: "value",
minInterval: 1,
name: props.yLeftUnit,
},
props.yRightUnit
? {
type: "value",
minInterval: 1,
name: props.yRightUnit,
position: "right",
axisLine: { show: true },
axisLabel: { show: true },
}
: undefined,
].filter(Boolean),
series: getSeries(),
});
}
})
})
});
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance.dispose();
}
})
});
</script>

View File

@ -1,5 +1,5 @@
<template>
<div :id="chartId" style="width: 100%; min-height: 250px"></div>
<div :id="chartId" :style="`width: 100%; min-height: ${minHeight}px`"></div>
</template>
<script setup>
@ -15,10 +15,23 @@ const props = defineProps({
type: String,
default: "",
},
yAxisName: {
type: String,
default: "MW",
},
minHeight: {
type: Number,
default: 250,
},
smooth: {
type: Boolean,
default: false,
},
});
const chartId = `lineChart-${Math.random().toString(36).slice(2)}`;
let chartInstance = null;
const minHeight = props.minHeight;
const xData = computed(() => props.data.map((item) => item.time));
const yData = computed(() => props.data.map((item) => item.value));
@ -45,8 +58,8 @@ onMounted(() => {
bottom: 0,
},
grid: {
left: 10,
right: 10,
left: 15,
right: 15,
top: 30,
bottom: 30,
containLabel: true,
@ -59,7 +72,7 @@ onMounted(() => {
yAxis: {
type: "value",
minInterval: 1,
name: "MW",
name: props.yAxisName,
},
series: [
{
@ -68,6 +81,7 @@ onMounted(() => {
data: yData.value,
symbol: "circle",
symbolSize: 8,
smooth: props.smooth,
lineStyle: {
width: 3,
color: "#409EFF",

View File

@ -0,0 +1,108 @@
<template>
<el-breadcrumb
separator="/"
style="margin: 15px 5px; font-size: 0.9rem; opacity: 0.7"
>
<el-breadcrumb-item
v-for="(item, idx) in breadcrumbs"
:key="item.path || idx"
:to="
item.path && idx !== breadcrumbs.length - 1
? { path: item.path }
: undefined
"
>
<span v-if="!item.path || idx === breadcrumbs.length - 1">{{
item.title || item.name
}}</span>
<span v-else>{{ item.title || item.name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { menuConfig } from "../../constants/menuConfig.js";
//
function findMenuItem(route) {
for (const menu of menuConfig) {
for (const child of menu.children) {
//
if (child.routeName === route.name || child.path === route.path) {
return { parent: menu, item: child };
}
// /plant/:id
if (
child.routeName === "PlantDetail" &&
route.path &&
route.path.startsWith("/plant/")
) {
return { parent: menu, item: child };
}
}
}
return null;
}
function buildBreadcrumb(route) {
const match = findMenuItem(route);
if (!match) return [];
const crumbs = [
{
name: match.parent.title,
title: match.parent.title,
path: null,
meta: { title: match.parent.title },
},
];
let itemTitle = match.item.title;
if (route.name === "PlantDetail" && route.params.id) {
const plantNames = {
"1": "四磺子坪",
"2": "宜蘭大清水",
"3": "宜蘭小清水",
};
itemTitle = plantNames[route.params.id] || match.item.title;
}
crumbs.push({
name: itemTitle,
title: itemTitle,
path: match.item.path,
meta: { title: itemTitle },
});
return crumbs;
}
const route = useRoute();
const breadcrumbs = computed(() => {
const menuBreadcrumbs = buildBreadcrumb(route);
if (menuBreadcrumbs.length > 0) {
return menuBreadcrumbs;
}
const matchedRoutes = route.matched.filter((r) => r.meta && r.meta.title);
if (matchedRoutes.length === 0) {
return [];
}
const currentRoute = matchedRoutes[matchedRoutes.length - 1];
if (currentRoute.meta.parentTitle) {
return [
{
title: currentRoute.meta.parentTitle,
path: null,
},
{
title: currentRoute.meta.title,
path: currentRoute.path,
},
];
}
return matchedRoutes.map((r) => ({
title: r.meta.title,
name: r.name,
path: r.path,
}));
});
</script>

View File

@ -0,0 +1,25 @@
<template>
<iframe
:src="src"
width="100%"
frameborder="0"
allowfullscreen
:style="{ minHeight: minHeight }"
></iframe>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
required: true,
},
minHeight: {
type: String,
default: 'calc(100vh - 156px)',
},
});
</script>
<style scoped>
</style>

View File

@ -22,7 +22,7 @@ const props = defineProps({
list-style: none;
padding: 0;
margin: 0;
height: 175px;
height: 180px;
overflow-y: auto;
}
.info-list li {

View File

@ -0,0 +1,93 @@
<template>
<el-card shadow="always" class="custom-card">
<template #header>
<span>今日發電量</span>
</template>
<div id="leaflet-map" style="height: 485px; width: 100%"></div>
</el-card>
</template>
<script setup>
import { onMounted, onUnmounted } from "vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import markerIconBlue from "../../assets/marker-icon-blue.png";
import markerShadow from "../../assets/marker-shadow.png";
const props = defineProps({
geothermalMarkers: Array,
});
let map = null;
onMounted(() => {
// Leaflet mapcontainer _leaflet_id
const container = document.getElementById("leaflet-map");
if (container && container._leaflet_id) {
try {
//
const oldMap = container._leaflet_id && L.map(container);
if (oldMap && oldMap.remove) oldMap.remove();
} catch (e) {
// noop
}
// id
delete container._leaflet_id;
}
// icon
const geothermalIcon = L.icon({
iconUrl: markerIconBlue,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: markerShadow,
shadowSize: [41, 41],
});
map = L.map("leaflet-map").setView([25.05, 121.6], 9);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
(props.geothermalMarkers || []).forEach(({ position, popup }) => {
L.marker(position, { icon: geothermalIcon })
.addTo(map)
.bindTooltip(
`<h4>${popup.title}</h4>
<div class='info'>
<div>發電量 : ${popup.desc}</div>
</div>
`,
{
direction: "top",
permanent: true,
className: "custom-tooltip",
offset: [0, -50],
}
);
});
});
onUnmounted(() => {
if (map) map.remove();
});
</script>
<style scoped>
:deep(.custom-tooltip) {
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
box-shadow: 0px 4px 8px rgba(44, 62, 80, 0.24);
padding: 10px;
}
:deep(.custom-tooltip) h4 {
margin-bottom: 5px;
font-size: 1rem;
color: #343a40;
margin-top: 0;
}
:deep(.custom-tooltip) img {
width: 100%;
border-radius: 10px;
}
:deep(.custom-tooltip) .info {
font-size: 0.9rem;
}
</style>

View File

@ -3,7 +3,7 @@
<div class="title-area">
<el-button class="collapse-btn" @click="toggleCollapse">
<img
v-if="collapseState"
v-if="props.isCollapse"
src="../assets/bars-3.svg"
alt="展開"
style="width: 24px; height: 24px"
@ -34,19 +34,18 @@
<script setup lang="ts">
import { computed, defineProps, defineEmits } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
const props = defineProps({
isCollapse: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:isCollapse"]);
const collapseState = computed({
get: () => props.isCollapse,
set: (val: boolean) => emit("update:isCollapse", val),
});
const toggleCollapse = () => {
collapseState.value = !collapseState.value;
emit("update:isCollapse", !props.isCollapse);
};
</script>

View File

@ -0,0 +1,21 @@
<template>
<el-card shadow="always" class="custom-card">
<template #header>
<span>詳細資料</span>
</template>
<el-table :data="detailData" style="width: 100%">
<el-table-column prop="date" label="時間" />
<el-table-column prop="kwh" label="發電量(kWh)" />
<el-table-column prop="hour" label="發電小時" />
<el-table-column prop="pr" label="PR(%)" />
<el-table-column prop="temppr" label="溫度修正PR(%)" />
<el-table-column prop="datapr" label="PR資料完整度(%)" />
</el-table>
</el-card>
</template>
<script setup>
const props = defineProps({
detailData: Array
});
</script>

View File

@ -0,0 +1,97 @@
<template>
<el-card shadow="always" class="custom-card">
<div class="header-row">
<el-radio-group
:model-value="radio"
@update:model-value="(val) => $emit('update:radio', val)"
size="large"
fill="#409eff"
>
<el-radio-button label="日區間" value="day" />
<el-radio-button label="月" value="month" />
<el-radio-button label="年" value="year" />
<el-radio-button label="歷年" value="all" />
</el-radio-group>
<template v-if="radio !== 'all'">
<el-radio-group
:model-value="radio2"
@update:model-value="
(val) => {
$emit('update:radio2', val);
handleRadio2Change(val);
}
"
size="large"
fill="#409eff"
>
<el-radio-button
v-if="radio === 'day'"
label="昨日"
value="yesterday"
/>
<el-radio-button v-if="radio === 'day'" label="今日" value="today" />
<el-radio-button
v-if="radio === 'month'"
label="上個月"
value="lastmonth"
/>
<el-radio-button
v-if="radio === 'month'"
label="這個月"
value="thismonth"
/>
<el-radio-button
v-if="radio === 'year'"
label="去年"
value="lastyear"
/>
<el-radio-button
v-if="radio === 'year'"
label="今年"
value="thisyear"
/>
</el-radio-group>
<el-date-picker
:model-value="dateValue"
@update:model-value="$emit('update:dateValue', $event)"
:type="datePickerType"
:placeholder="datePickerPlaceholder"
size="large"
/>
</template>
<div class="button-row">
<el-button type="primary" size="large" :icon="Search">查詢</el-button>
<el-button type="success" size="large" :icon="Printer">查詢</el-button>
</div>
</div>
</el-card>
</template>
<script setup>
import { Search, Printer } from "@element-plus/icons-vue";
const props = defineProps({
radio: String,
radio2: String,
dateValue: [String, Object],
datePickerType: String,
datePickerPlaceholder: String,
});
const emit = defineEmits([
"update:radio",
"update:radio2",
"update:dateValue",
"radio2Change",
]);
function handleRadio2Change(val) {
emit("radio2Change", val);
}
</script>
<style scoped>
.header-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
</style>

View File

@ -0,0 +1,21 @@
<template>
<el-card shadow="always" class="custom-card">
<template #header>
<span>總結</span>
</template>
<el-table :data="totalData" style="width: 100%">
<el-table-column prop="date" label="時間" />
<el-table-column prop="kwh" label="發電量(kWh)" />
<el-table-column prop="hour" label="發電小時" />
<el-table-column prop="pr" label="PR(%)" />
<el-table-column prop="temppr" label="溫度修正PR(%)" />
<el-table-column prop="datapr" label="PR資料完整度(%)" />
</el-table>
</el-card>
</template>
<script setup>
const props = defineProps({
totalData: Array
});
</script>

View File

@ -0,0 +1,85 @@
<template>
<el-card shadow="always" class="custom-card" body-style="padding: 0">
<template #header>
<el-input
:model-value="input"
@update:model-value="$emit('update:input', $event)"
placeholder="搜尋關鍵字"
:suffix-icon="Search"
/>
</template>
<el-menu :default-openeds="openMenus" class="el-menu-demo" mode="vertical">
<el-sub-menu index="1">
<template #title>
<el-icon><Help /></el-icon>
<span>新北市</span>
</template>
<el-menu-item index="1-1">
<el-checkbox
:model-value="checkedArr[0]"
@update:model-value="(val) => updateCheckedArr(0, val)"
label="四磺子坪"
size="large"
/>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="2">
<template #title>
<el-icon><Help /></el-icon>
<span> 宜蘭縣</span>
</template>
<el-menu-item index="2-1">
<el-checkbox
:model-value="checkedArr[1]"
@update:model-value="(val) => updateCheckedArr(1, val)"
label="宜蘭小清水站"
size="large"
/>
</el-menu-item>
<el-menu-item index="2-2">
<el-checkbox
:model-value="checkedArr[2]"
@update:model-value="(val) => updateCheckedArr(2, val)"
label="宜蘭結元清水站"
size="large"
/>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-card>
</template>
<script setup>
import { ref } from "vue";
import { Help, Search } from "@element-plus/icons-vue";
const props = defineProps({
input: String,
checkedArr: Array,
openMenus: Array,
});
const emit = defineEmits(["update:input", "update:checkedArr"]);
function updateCheckedArr(idx, val) {
const arr = [...props.checkedArr];
arr[idx] = val;
emit("update:checkedArr", arr);
}
</script>
<style scoped>
:deep(.el-sub-menu__title) {
font-size: 1rem;
box-shadow: 0px 0px 13px 0px rgba(74, 53, 107, 0.08);
border-bottom: 1px solid #f0f0f0;
}
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background-color: #627ca0;
color: #fff;
}
/* 子選單內容 */
:deep(.el-menu-item) {
border-bottom: 1px solid #e8ebf1;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<el-menu
:default-active="$route.name"
:default-active="activeMenuIndex"
class="el-menu-vertical-demo"
background-color="#00152a"
active-text-color="#fff"
@ -15,76 +15,32 @@
/>
<span>結元能源</span>
</div>
<el-sub-menu index="overview">
<el-sub-menu v-for="menu in menuConfig" :key="menu.id" :index="menu.id">
<template #title>
<el-icon><Location /></el-icon>
<span>總覽</span>
<el-icon>
<component :is="getIconComponent(menu.icon)" />
</el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item index="PlantsMap" @click="$router.push('/plantsMap')">地圖總覽</el-menu-item>
<el-menu-item index="PlantsOverview" @click="$router.push('/plants')">電廠總覽</el-menu-item>
</el-sub-menu>
<el-sub-menu index="factory-info">
<template #title>
<el-icon><Postcard /></el-icon>
<span>電廠資訊</span>
</template>
<el-menu-item index="factory-1">四磺子坪</el-menu-item>
<el-menu-item index="factory-2">宜蘭大清水</el-menu-item>
<el-menu-item index="factory-3">宜蘭小清水</el-menu-item>
</el-sub-menu>
<el-sub-menu index="inspection">
<template #title>
<el-icon><DocumentChecked /></el-icon>
<span>巡檢系統</span>
</template>
<el-menu-item index="inspection-task">巡檢任務</el-menu-item>
<el-menu-item index="inspection-setting">巡檢設定</el-menu-item>
</el-sub-menu>
<el-sub-menu index="report">
<template #title>
<el-icon><DataLine /></el-icon>
<span>報表查詢</span>
</template>
<el-menu-item index="report-factory">電廠報表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="alert">
<template #title>
<el-icon><Bell /></el-icon>
<span>即時告警</span>
</template>
<el-menu-item index="alert-event">異常事件查詢</el-menu-item>
</el-sub-menu>
<el-sub-menu index="material">
<template #title>
<el-icon><MessageBox /></el-icon>
<span>備料管理</span>
</template>
<el-menu-item index="material-item">備品料件管理</el-menu-item>
<el-menu-item index="material-location">倉庫櫃位管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="security">
<template #title>
<el-icon><VideoCamera /></el-icon>
<span>智慧安防</span>
</template>
<el-menu-item index="security-system">安防系統</el-menu-item>
</el-sub-menu>
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系統設定</span>
</template>
<el-menu-item index="system-factory">電廠設定</el-menu-item>
<el-menu-item index="system-account">帳號設定</el-menu-item>
<el-menu-item
v-for="item in menu.children"
:key="item.id"
:index="item.path || item.id"
@click="handleMenuClick(item)"
>
{{ item.title }}
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { computed, toRefs, defineProps } from "vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { menuConfig } from "../constants/menuConfig.js";
import {
Document,
Menu as IconMenu,
Location,
Setting,
Postcard,
@ -92,8 +48,52 @@ import {
DataLine,
Bell,
MessageBox,
VideoCamera
VideoCamera,
} from "@element-plus/icons-vue";
const route = useRoute();
const router = useRouter();
//
const iconComponents = {
Location,
Setting,
Postcard,
DocumentChecked,
DataLine,
Bell,
MessageBox,
VideoCamera,
};
//
const getIconComponent = (iconName) => {
return iconComponents[iconName] || Location;
};
//
const handleMenuClick = (item) => {
if (item.path) {
if (item.routeName && item.params) {
router.push({ name: item.routeName, params: item.params });
} else {
router.push(item.path);
}
}
};
//
const activeMenuIndex = computed(() => {
//
for (const menu of menuConfig) {
for (const item of menu.children) {
if (item.routeName === route.name || item.path === route.path) {
return item.path || item.id;
}
}
}
return route.name || route.path;
});
</script>
<style scoped>
@ -141,9 +141,9 @@ import {
/* 為 menu-item 添加圓點 */
:deep(.el-menu-item::before) {
content: '';
content: "";
position: absolute;
left:30px;
left: 30px;
top: 50%;
transform: translateY(-50%);
width: 6px;
@ -154,9 +154,9 @@ import {
/* 選中狀態的圓點為藍色 */
:deep(.el-menu-item.is-active::after) {
content: '';
content: "";
position: absolute;
right:25px;
right: 25px;
top: 50%;
transform: translateY(-50%);
width: 6px;
@ -179,12 +179,12 @@ import {
}
:deep(.el-menu.el-menu--inline::before) {
content: '';
content: "";
position: absolute;
left:32.5px;
left: 32.5px;
top: 50%;
transform: translateY(-50%);
width: .8px;
width: 0.8px;
height: 110%;
background-color: #95a2b5;
z-index: 80;

129
src/constants/menuConfig.js Normal file
View File

@ -0,0 +1,129 @@
export const menuConfig = [
{
id: 'overview',
title: '總覽',
icon: 'Location',
children: [
{
id: 'PlantsMap',
title: '地圖總覽',
path: '/plantsMap',
routeName: 'PlantsMap'
},
{
id: 'PlantsOverview',
title: '電廠總覽',
path: '/plants',
routeName: 'PlantsOverview'
}
]
},
{
id: 'factory-info',
title: '電廠資訊',
icon: 'Postcard',
children: [
{
id: 'plant-1',
title: '四磺子坪',
path: '/plant/1',
routeName: 'PlantDetail',
params: { id: 1 }
},
{
id: 'plant-2',
title: '宜蘭大清水',
path: '/plant/2',
routeName: 'PlantDetail',
params: { id: 2 }
},
{
id: 'plant-3',
title: '宜蘭小清水',
path: '/plant/3',
routeName: 'PlantDetail',
params: { id: 3 }
}
]
},
{
id: 'inspection',
title: '巡檢系統',
icon: 'DocumentChecked',
children: [
{
id: 'inspection-task',
title: '巡檢任務'
},
{
id: 'inspection-setting',
title: '巡檢設定'
}
]
},
{
id: 'report',
title: '報表查詢',
icon: 'DataLine',
children: [
{
id: 'PlantsReport',
title: '電廠報表',
path: '/plantsReport',
routeName: 'PlantsReport'
}
]
},
{
id: 'alert',
title: '即時告警',
icon: 'Bell',
children: [
{
id: 'alert-event',
title: '異常事件查詢'
}
]
},
{
id: 'material',
title: '備料管理',
icon: 'MessageBox',
children: [
{
id: 'material-item',
title: '備品料件管理'
},
{
id: 'material-location',
title: '倉庫櫃位管理'
}
]
},
{
id: 'security',
title: '智慧安防',
icon: 'VideoCamera',
children: [
{
id: 'security-system',
title: '安防系統'
}
]
},
{
id: 'system',
title: '系統設定',
icon: 'Setting',
children: [
{
id: 'system-factory',
title: '電廠設定'
},
{
id: 'system-account',
title: '帳號設定'
}
]
}
];

View File

@ -1,5 +1,5 @@
import { createApp } from "vue";
import "./style.css";
import "./styles/style.css";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";

View File

@ -1,21 +1,52 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "../views/Home.vue";
import PlantsOverview from "../views/PlantsOverview.vue";
import PlantReport from "../views/PlantReport.vue";
import PlantDetail from "../views/PlantDetail.vue";
const routes = [
{
path: "/plantsMap",
name: "PlantsMap",
component: Home,
meta: { title: "地圖總覽", icon: "Location", menuGroup: "overview" },
meta: {
title: "地圖總覽",
icon: "Location",
menuGroup: "overview",
parentTitle: "總覽",
},
},
{
path: "/plants",
name: "PlantsOverview",
component: PlantsOverview,
meta: { title: "電廠總覽", icon: "Postcard", menuGroup: "overview" },
meta: {
title: "電廠總覽",
icon: "Postcard",
menuGroup: "overview",
parentTitle: "總覽"
},
},
{
path: "/plant/:id",
name: "PlantDetail",
component: PlantDetail,
meta: {
title: "電廠詳細",
menuGroup: "factory-info",
parentTitle: "電廠資訊",
},
},
{
path: "/plantsReport",
name: "PlantsReport",
component: PlantReport,
meta: {
title: "電廠報表",
menuGroup: "report",
parentTitle: "報表查詢",
},
},
// 你可以依需求繼續擴充其他路由
];
const router = createRouter({

View File

@ -11,6 +11,7 @@
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
@ -33,7 +34,8 @@ body {
.el-card.custom-card .el-card__header{
font-weight: 600;
font-size: 1rem;
font-size: 1.2rem;
letter-spacing: 0.8px;
color: #213547;
}
}

View File

@ -1,15 +1,11 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue";
import PieChart from "../components/Chart/PieChart.vue";
import PlantMapCard from "../components/Home/PlantMapCard.vue";
import InfoList from "../components/Home/InfoList.vue";
import LineChart from "../components/Chart/LineChart.vue";
import ComboBarLineChart from "../components/Chart/ComboBarLineChart.vue";
import InfoList from "../components/InfoList.vue";
import markerIconBlue from "../assets/marker-icon-blue.png";
import markerShadow from "../assets/marker-shadow.png";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const avgData = [
import ComboBarLineChart from '../components/Chart/ComboBarLineChart.vue';
const rawAvgData = [
{
year: 2021,
data: [
@ -52,6 +48,12 @@ const avgData = [
},
];
const avgData = rawAvgData.map((item) => ({
year: item.year,
...Object.fromEntries(item.data.map((d) => [d.name, d.value])),
total: item.data.reduce((sum, d) => sum + d.value, 0),
}));
const weekTrend = [
{ time: "周一", value: 120 },
{ time: "周二", value: 132 },
@ -63,6 +65,18 @@ const weekTrend = [
];
const unconfirmed = ref(2014);
//
const abnormalItems = [
{ label: "已復歸", value: 1234, style: "color: #67C23A; font-size: 2rem;" },
{ label: "未復歸", value: 1345, style: "color: #F56C6C; font-size: 2rem;" },
{ label: "已確認", value: 1120, style: "color: #409EFF; font-size: 2rem;" },
{
label: "未確認",
value: unconfirmed,
style: "color: #E6A23C; font-size: 2rem;",
},
];
const elecData = ref([
{ value: 42, name: "大清水" },
{ value: 26, name: "小清水" },
@ -82,16 +96,6 @@ const plantAccumulate = reactive([
{ label: "四磺子坪", value: 100, unit: "MW" },
]);
// icon
const geothermalIcon = L.icon({
iconUrl: markerIconBlue,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: markerShadow,
shadowSize: [41, 41],
});
// marker
const geothermalMarkers = [
{
@ -118,32 +122,15 @@ const geothermalMarkers = [
let timer = null;
const yearlySeries = [
{ type: "bar", field: "大清水", name: "大清水", color: "#5070dd" },
{ type: "bar", field: "小清水", name: "小清水", color: "#b6d634" },
{ type: "bar", field: "四磺子坪", name: "四磺子坪", color: "#505372" },
{ type: "line", field: "total", name: "總發電量", color: "#409EFF" },
];
onMounted(() => {
// OpenStreetMap
const map = L.map("leaflet-map").setView([25.05, 121.6], 9);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
geothermalMarkers.forEach(({ position, popup }) => {
L.marker(position, { icon: geothermalIcon })
.addTo(map)
.bindTooltip(
`<h4>${popup.title}</h4>
<div class="info">
<div>發電量 : ${popup.desc}</div>
</div>
`,
{
direction: "top",
permanent: true,
className: "custom-tooltip",
offset: [0, -50], // Y 30px
}
);
});
// timer
timer = setInterval(() => {
unconfirmed.value++;
@ -162,42 +149,26 @@ onUnmounted(() => {
<template>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24" :lg="7">
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>異常狀態</span>
</template>
<el-row :gutter="20">
<el-col :span="12" style="margin-bottom: 10px">
<el-col
v-for="(item, idx) in abnormalItems"
:key="idx"
:span="12"
:style="idx % 2 === 0 ? 'margin-bottom: 10px' : ''"
>
<el-statistic
title="已復歸"
:value="1234"
value-style="color: #67C23A; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="未復歸"
:value="1345"
value-style="color: #F56C6C; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="已確認"
:value="1120"
value-style="color: #409EFF; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="未確認"
:value="unconfirmed"
value-style="color: #E6A23C; font-size: 2rem;"
:title="item.label"
:value="item.value"
:value-style="item.style || 'font-size: 2rem;'"
/>
</el-col>
</el-row>
</el-card>
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>發電分布</span>
</template>
@ -205,21 +176,16 @@ onUnmounted(() => {
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
<el-card shadow class="custom-card">
<template #header>
<span>今日發電量</span>
</template>
<div id="leaflet-map" style="height: 470px; width: 100%"></div>
</el-card>
<PlantMapCard :geothermalMarkers="geothermalMarkers" />
</el-col>
<el-col :xs="24" :lg="7">
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>電廠概覽</span>
</template>
<InfoList :data="plantOverview" />
</el-card>
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>累積發電</span>
</template>
@ -227,7 +193,7 @@ onUnmounted(() => {
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>發電趨勢</span>
</template>
@ -235,39 +201,23 @@ onUnmounted(() => {
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow class="custom-card">
<el-card shadow="always" class="custom-card">
<template #header>
<span>年平均發電比較</span>
</template>
<ComboBarLineChart :data="avgData" />
<ComboBarLineChart
:data="avgData"
xField="year"
:series="yearlySeries"
yLeftUnit="MW"
/>
</el-card>
</el-col>
</el-row>
</template>
<script setup></script>
<style scoped>
:deep(.el-statistic__head) {
font-size: 0.85rem;
}
:deep(.custom-tooltip) {
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
box-shadow: 0px 4px 8px rgba(44, 62, 80, 0.24);
padding: 10px;
}
:deep(.custom-tooltip) h4 {
margin-bottom: 5px;
font-size: 1rem;
color: #343a40;
margin-top: 0;
}
:deep(.custom-tooltip) img {
width: 100%;
border-radius: 10px;
}
:deep(.custom-tooltip) .info {
font-size: .9rem;
}
</style>

139
src/views/PlantDetail.vue Normal file
View File

@ -0,0 +1,139 @@
<template>
<Breadcrumb />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24">
<el-card
shadow="always"
class="custom-card"
style="margin-bottom: 0"
:body-style="{ paddingTop: '16px', paddingBottom: '0' }"
>
<template #header>
<span style="">{{ title }}</span>
</template>
<el-tabs
v-model="activeName"
type="border-card"
class="demo-tabs"
@tab-click="handleClick"
>
<el-tab-pane
v-for="tab in tabList"
:key="tab.name"
:name="tab.name"
>
<template #label>
<el-icon><component :is="tab.icon" /></el-icon>
<span style="margin-left: 4px">{{ tab.label }}</span>
</template>
<template v-if="tab.src && activeName === tab.name">
<CustomIframe :src="tab.src" minHeight="calc(100vh - 300px)" />
</template>
<template v-else-if="tab.name === 'abnormal-record'">
異常紀錄
</template>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import Breadcrumb from "../components/Common/Breadcrumb.vue";
import CustomIframe from "../components/Common/CustomIframe.vue";
import {
Odometer,
Postcard,
ScaleToOriginal,
Document,
Coin,
Warning,
Operation,
} from "@element-plus/icons-vue";
const router = useRouter();
const route = useRoute();
const title = computed(() => {
if (route.params.id === "1") return "四磺子坪";
if (route.params.id === "2") return "宜蘭大清水";
if (route.params.id === "3") return "宜蘭小清水";
return "";
});
const siteId = computed(() => {
if (route.params.id === "1") return "J022270001";
if (route.params.id === "2") return "J033110001";
if (route.params.id === "3") return "J033110002";
return "";
});
const activeName = ref("system-status");
const handleClick = (tab, event) => {
console.log("tab", tab, event);
};
const tabList = computed(() => [
{
name: "system-status",
label: "系統狀態",
icon: Odometer,
src: `/ord/station:|slot:/${siteId.value}/PID|view:?fullScreen=true`,
},
{
name: "realtime-info",
label: "即時資訊",
icon: Postcard,
src: `/ord/station:|slot:/${siteId.value}/RealTimeInformation|view:?fullScreen=true`,
},
{
name: "basic-info",
label: "基本資料",
icon: ScaleToOriginal,
src: `/ord/station:|slot:/${siteId.value}/Information|view:?fullScreen=true`,
},
{
name: "device-info",
label: "設備資訊",
icon: Document,
src: `/ord/station:|slot:/${siteId.value}/DeviceInformation|view:?fullScreen=true`,
},
{
name: "history-data",
label: "歷史資料",
icon: Coin,
src: `/ord/station:|slot:/${siteId.value}/History|view:?fullScreen=true`,
},
{
name: "abnormal-record",
label: "異常紀錄",
icon: Warning,
src: null,
},
]);
</script>
<style scoped>
:deep(.el-tabs__header) {
background-color: transparent;
}
.el-tabs--border-card {
border: none;
}
:deep(.el-tabs__item) {
margin-top: 0px !important;
margin-left: 0px !important;
font-size: 1rem;
}
:deep(.el-tabs__item.is-active) {
border-top-color: var(--el-border-color);
}
:deep(.el-tabs--border-card) > .el-tabs__content {
padding: 10px 0px;
}
</style>

142
src/views/PlantReport.vue Normal file
View File

@ -0,0 +1,142 @@
<template>
<Breadcrumb />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24" :md="5">
<SiteMenu
:input="input"
:checkedArr="checkedArr"
:openMenus="openMenus"
@update:input="(val) => (input = val)"
@update:checkedArr="(val) => (checkedArr = val)"
/>
</el-col>
<el-col :xs="24" :md="19">
<ReportFilter
:radio="radio"
:radio2="radio2"
:dateValue="dateValue"
:datePickerType="datePickerType"
:datePickerPlaceholder="datePickerPlaceholder"
@update:radio="(val) => (radio = val)"
@update:radio2="(val) => (radio2 = val)"
@update:dateValue="(val) => (dateValue = val)"
@radio2Change="handleRadio2Change"
/>
<ReportSummaryTable :totalData="totalData" />
<el-card shadow="always" class="custom-card">
<ComboBarLineChart
:data="chartData"
:xField="'date'"
:series="plantSeries"
yLeftUnit="kWh"
yRightUnit="%"
:showToolbox="true"
/>
</el-card>
<ReportDetailTable :detailData="detailData" />
</el-col>
</el-row>
</template>
<script setup>
import { ref, computed } from "vue";
import dayjs from "dayjs";
import Breadcrumb from "../components/Common/Breadcrumb.vue";
import SiteMenu from "../components/Report/SiteMenu.vue";
import ReportFilter from "../components/Report/ReportFilter.vue";
import ReportSummaryTable from "../components/Report/ReportSummaryTable.vue";
import ComboBarLineChart from "../components/Chart/ComboBarLineChart.vue";
import ReportDetailTable from "../components/Report/ReportDetailTable.vue";
const radio = ref("month");
const radio2 = ref("thismonth");
const dateValue = ref(dayjs().format("YYYY-MM-DD"));
const openMenus = ref(["1", "2"]);
const input = ref("");
const datePickerType = computed(() => {
if (radio.value === "day") return "date";
if (radio.value === "month") return "month";
if (radio.value === "year") return "year";
if (radio.value === "all") return "daterange";
return "date";
});
const datePickerPlaceholder = computed(() => {
if (radio.value === "day") return "選擇日期";
if (radio.value === "month") return "選擇月份";
if (radio.value === "year") return "選擇年份";
if (radio.value === "all") return "";
return "選擇日期";
});
function handleRadio2Change(val) {
if (radio.value === "month") {
if (val === "lastmonth") {
dateValue.value = dayjs().subtract(1, "month").format("YYYY-MM");
} else if (val === "thismonth") {
dateValue.value = dayjs().format("YYYY-MM");
}
} else if (radio.value === "year") {
if (val === "lastyear") {
dateValue.value = dayjs().subtract(1, "year").format("YYYY");
} else if (val === "thisyear") {
dateValue.value = dayjs().format("YYYY");
}
} else if (radio.value === "day") {
if (val === "yesterday") {
dateValue.value = dayjs().subtract(1, "day").format("YYYY-MM-DD");
} else if (val === "today") {
dateValue.value = dayjs().format("YYYY-MM-DD");
}
}
}
//
const checkedArr = ref([false, false, false]);
// (20)
const detailData = Array.from({ length: 20 }, (_, i) => {
const day = (i + 1).toString().padStart(2, "0");
return {
date: `2025-10-${day}`,
kwh: (Math.random() * 100 + 100).toFixed(2),
hour: (Math.random() * 10 + 10).toFixed(2),
pr: (Math.random() * 10 + 90).toFixed(2),
temppr: (Math.random() * 10 + 90).toFixed(2),
datapr: (Math.random() * 10 + 90).toFixed(2),
};
});
// (1)
const totalData = [
{
date: "2025-10",
kwh: detailData.reduce((sum, d) => sum + parseFloat(d.kwh), 0).toFixed(2),
hour: detailData.reduce((sum, d) => sum + parseFloat(d.hour), 0).toFixed(2),
pr: (
detailData.reduce((sum, d) => sum + parseFloat(d.pr), 0) /
detailData.length
).toFixed(2),
temppr: (
detailData.reduce((sum, d) => sum + parseFloat(d.temppr), 0) /
detailData.length
).toFixed(2),
datapr: (
detailData.reduce((sum, d) => sum + parseFloat(d.datapr), 0) /
detailData.length
).toFixed(2),
},
];
// x bar line
const chartData = detailData.map((d) => ({
date: dayjs(d.date).format("MM-DD"),
kwh: Number(d.kwh),
temp: Number(d.temppr),
}));
//
const plantSeries = [
{ type: "bar", field: "kwh", name: "發電量", color: "#91cc75" },
{ type: "line", field: "temp", name: "溫度修正PR(%)", color: "#409EFF" },
];
</script>
<style scoped></style>

View File

@ -0,0 +1,333 @@
<template>
<Breadcrumb />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24">
<el-card shadow="always" class="custom-card">
<template #header>
<span style="">電廠總覽</span>
</template>
<el-row :gutter="20">
<el-col :xs="24" style="margin-bottom: 15px">
<div class="button-row">
<el-button type="info" size="large">全選</el-button>
<el-button type="primary" size="large"
>新北市
<span class="badge">1</span>
</el-button>
<el-button type="primary" size="large"
>宜蘭縣
<span class="badge">2</span>
</el-button>
</div>
</el-col>
<el-col :xs="24" style="margin-bottom: 15px">
<div class="button-row" style="display: flex; gap: 12px">
<el-button type="info" size="large">全選</el-button>
<el-checkbox
v-model="checked1"
size="large"
style="margin: 0"
border
>
<span style="vertical-align: middle; margin-right: 4px"
>設備正常</span
>
<el-icon
style="vertical-align: middle"
color="#67C23A"
size="large"
><CircleCheckFilled
/></el-icon>
</el-checkbox>
<el-checkbox
v-model="checked2"
size="large"
style="margin: 0"
border
>
<span style="vertical-align: middle; margin-right: 4px"
>設備斷線</span
>
<el-icon
style="vertical-align: middle"
color="#E6A23C"
size="large"
><WarningFilled
/></el-icon>
</el-checkbox>
<el-checkbox v-model="checked3" size="large" border>
<span style="vertical-align: middle; margin-right: 4px"
>設備斷線</span
>
<el-icon
style="vertical-align: middle"
color="#F56C6C"
size="large"
><RemoveFilled
/></el-icon>
</el-checkbox>
</div>
</el-col>
<el-col :xs="24" style="margin-bottom: 15px">
<div
class="button-row"
style="display: flex; gap: 10px; align-items: center"
>
<el-button size="large" text>排序條件</el-button>
<el-select
v-model="value1"
placeholder="Select"
style="width: 150px"
size="large"
>
<el-option
v-for="item in options1"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="value2"
placeholder="Select"
style="width: 150px"
size="large"
>
<el-option
v-for="item in options2"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
<el-col :xs="24">
<el-row :gutter="40" style="margin-top: 10px">
<el-col
v-for="plant in plants"
:key="plant.name"
:xs="24"
:sm="12"
:md="8"
>
<el-card class="plants-card">
<div class="plant-img-wrap">
<img :src="plant.img" class="plant-img" />
</div>
<div class="plant-info-tilte">
<el-icon :color="plant.statusColor" size="25">
<component :is="plant.statusIcon" />
</el-icon>
<h3>{{ plant.name }}</h3>
<p>
<span>{{ plant.temp }}</span>
<el-icon size="25">
<Sunny />
</el-icon>
</p>
</div>
<ul class="plant-info-list">
<li>
<span>發電量</span>
<div>
<span class="value">{{ plant.power }}</span>
<span>kWh</span>
</div>
</li>
<li>
<span>PR值</span>
<div>
<span class="value">{{ plant.pr }}</span>
<span>%</span>
</div>
</li>
</ul>
<LineChart
:data="plant.weekTrend"
:minHeight="180"
yAxisName="kWh"
:smooth="true"
/>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
</template>
<script setup>
import { ref } from "vue";
import { ElIcon } from "element-plus";
import {
CircleCheckFilled,
WarningFilled,
RemoveFilled,
Sunny,
} from "@element-plus/icons-vue";
const checked1 = ref(false);
const checked2 = ref(false);
const checked3 = ref(false);
const value1 = ref(1);
const value2 = ref(1);
const options1 = [
{ value: 1, label: "發電量 - 正序" },
{ value: 2, label: "發電量 - 倒序" },
];
const options2 = [
{ value: 1, label: "PR值 - 正序" },
{ value: 2, label: "PR值 - 倒序" },
];
const plants = [
{
name: "四磺子坪",
power: 185,
pr: 90,
temp: "25°C",
status: "正常",
statusColor: "#67C23A",
statusIcon: CircleCheckFilled,
img: "https://192.168.0.206:8820/file/images/plants/plant01.png",
weekTrend: [
{ time: "周一", value: 120 },
{ time: "周二", value: 132 },
{ time: "周三", value: 121 },
{ time: "周四", value: 134 },
{ time: "周五", value: 180 },
{ time: "周六", value: 230 },
{ time: "周日", value: 210 },
],
},
{
name: "大清水",
power: 170,
pr: 91,
temp: "27°C",
status: "正常",
statusColor: "#67C23A",
statusIcon: CircleCheckFilled,
img: "https://192.168.0.206:8820/file/images/plants/plant02.png",
weekTrend: [
{ time: "周一", value: 110 },
{ time: "周二", value: 120 },
{ time: "周三", value: 95 },
{ time: "周四", value: 230 },
{ time: "周五", value: 150 },
{ time: "周六", value: 200 },
{ time: "周日", value: 110 },
],
},
{
name: "大清水",
power: 117,
pr: 93,
temp: "27°C",
status: "正常",
statusColor: "#67C23A",
statusIcon: CircleCheckFilled,
img: "https://192.168.0.206:8820/file/images/plants/plant03.png",
weekTrend: [
{ time: "周一", value: 100 },
{ time: "周二", value: 140 },
{ time: "周三", value: 120 },
{ time: "周四", value: 180 },
{ time: "周五", value: 140 },
{ time: "周六", value: 200 },
{ time: "周日", value: 120 },
],
},
];
</script>
<style scoped>
.badge {
padding: 0.25em 0.6em;
font-size: 75%;
line-height: 1.3;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 4px;
background: var(--el-color-primary-dark-2);
margin-left: 0.6em;
}
:deep(.el-card.plants-card) {
border-radius: 6px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
background: #fff;
overflow: hidden;
padding: 0;
}
:deep(.el-card.plants-card) .el-card__body {
padding: 0;
}
.plant-img-wrap {
width: 100%;
height: 250px;
background: #eee;
overflow: hidden;
}
.plant-img {
width: 100%;
object-fit: cover;
display: block;
}
.plant-info-tilte {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
background: #fff;
gap: 10px;
border-bottom: 1px solid #f0f0f0;
font-size: 1rem;
color: #666;
}
.plant-info-tilte h3 {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
flex: 1;
}
.plant-info-tilte p {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.plant-info-list {
list-style: none;
padding: 10px 20px;
margin: 0;
}
.plant-info-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 8px 0;
font-size: 1rem;
}
.plant-info-list .value {
color: #409eff;
font-size: 1.2rem;
font-weight: bold;
margin: 0 8px;
width: 60%;
text-align: center;
}
</style>

View File

@ -1,14 +1,17 @@
<template>
<h1>電廠總覽</h1>
<p>這裡是電廠總覽頁面請依需求補充內容</p>
<Breadcrumb />
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24">
<CustomIframe
src="/ord/file:^px/All.px|view:hx:HxPxView|view:?fullScreen=true"
minHeight="calc(100vh - 156px)"
/>
</el-col>
</el-row>
</template>
<script setup>
//
import Breadcrumb from "../components/Common/Breadcrumb.vue";
import CustomIframe from "../components/Common/CustomIframe.vue";
</script>
<style scoped>
.plants-overview {
padding: 2rem;
}
</style>
<style scoped></style>

View File

@ -6,7 +6,7 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
// https://vite.dev/config/
export default defineConfig({
base: './',
base: "./",
plugins: [
vue(),
AutoImport({
@ -14,6 +14,7 @@ export default defineConfig({
dts: "src/auto-imports.d.ts",
}),
Components({
dirs: [], // 不自動掃描本地元件
resolvers: [ElementPlusResolver()],
dts: "src/components.d.ts",
}),