feat: add 3D interactive dashboard and 2D BigScreen pages

- New /bigscreen-3d route: React Three Fiber 3D campus with buildings,
  PV panels, heat pumps, meters, and sensors — all procedural geometry
- Interactive: hover highlight, click to select, camera fly-in to
  device detail views (PV inverter, heat pump, meter, heat meter, sensor)
- Real-time data: 15s polling for overview, 5s for selected device
- Energy flow particles along PV→Building, Grid→Building, Building→HP paths
- HUD overlay with date/clock, bottom metrics bar, device list panel
- New /bigscreen route: 2D dashboard with energy flow diagram, charts
- New /devices route: device management page
- Vite config: optimizeDeps.force for R3F dep consistency
- Data backfill script for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-01 22:43:48 +08:00
parent f53a610a19
commit 6a59f9af76
37 changed files with 5351 additions and 37 deletions

View File

@@ -4,7 +4,7 @@
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"runtimeArgs": ["run", "dev", "--", "--force"],
"port": 3000,
"cwd": "frontend"
},

View File

@@ -10,6 +10,9 @@
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"antd": "^5.29.3",
"axios": "^1.14.0",
"dayjs": "^1.11.20",
@@ -17,13 +20,15 @@
"echarts-for-react": "^3.0.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -989,6 +994,12 @@
"node": ">=10"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -1057,31 +1068,6 @@
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
@@ -1364,6 +1350,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.17",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0"
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
@@ -1566,6 +1570,121 @@
"react-dom": ">=18.0.0"
}
},
"node_modules/@react-three/drei": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
"integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mediapipe/tasks-vision": "0.10.17",
"@monogrid/gainmap-js": "^3.0.6",
"@use-gesture/react": "^10.3.1",
"camera-controls": "^3.1.0",
"cross-env": "^7.0.3",
"detect-gpu": "^5.0.56",
"glsl-noise": "^0.0.0",
"hls.js": "^1.5.17",
"maath": "^0.10.8",
"meshline": "^3.3.1",
"stats-gl": "^2.2.8",
"stats.js": "^0.17.0",
"suspend-react": "^0.1.3",
"three-mesh-bvh": "^0.8.3",
"three-stdlib": "^2.35.6",
"troika-three-text": "^0.52.4",
"tunnel-rat": "^0.1.2",
"use-sync-external-store": "^1.4.0",
"utility-types": "^3.11.0",
"zustand": "^5.0.1"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19",
"react-dom": "^19",
"three": ">=0.159"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/@react-three/fiber": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"its-fine": "^2.0.0",
"react-use-measure": "^2.1.7",
"scheduler": "^0.27.0",
"suspend-react": "^0.1.3",
"use-sync-external-store": "^1.4.0",
"zustand": "^5.0.3"
},
"peerDependencies": {
"expo": ">=43.0",
"expo-asset": ">=8.4",
"expo-file-system": ">=11.0",
"expo-gl": ">=11.0",
"react": ">=19 <19.3",
"react-dom": ">=19 <19.3",
"react-native": ">=0.78",
"three": ">=0.156"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"expo-asset": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-gl": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@react-three/postprocessing": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz",
"integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==",
"license": "MIT",
"dependencies": {
"maath": "^0.6.0",
"n8ao": "^1.9.4",
"postprocessing": "^6.36.6"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19.0",
"three": ">= 0.156.0"
}
},
"node_modules/@react-three/postprocessing/node_modules/maath": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz",
"integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.144.0",
"three": ">=0.144.0"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1837,6 +1956,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1848,6 +1973,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1873,11 +2004,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1894,6 +2030,43 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
@@ -2205,6 +2378,24 @@
"react": "*"
}
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -2231,6 +2422,12 @@
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2435,6 +2632,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
@@ -2448,6 +2665,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -2494,6 +2720,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"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",
@@ -2517,6 +2767,19 @@
"node": ">=6"
}
},
"node_modules/camera-controls": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
"license": "MIT",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.5.1"
},
"peerDependencies": {
"three": ">=0.126.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001782",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
@@ -2631,11 +2894,28 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"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",
@@ -2701,6 +2981,15 @@
"node": ">=6"
}
},
"node_modules/detect-gpu": {
"version": "5.0.70",
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
"license": "MIT",
"dependencies": {
"webgl-constants": "^1.1.1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2711,6 +3000,12 @@
"node": ">=8"
}
},
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3053,6 +3348,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3237,6 +3538,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glsl-noise": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3315,6 +3622,32 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3325,6 +3658,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3381,13 +3720,30 @@
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
"license": "MIT"
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"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/its-fine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.9"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3487,6 +3843,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3805,6 +4170,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/maath": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.134.0",
"three": ">=0.134.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3814,6 +4189,21 @@
"node": ">= 0.4"
}
},
"node_modules/meshline": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3855,6 +4245,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/n8ao": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz",
"integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
"three": ">=0.137"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -3974,7 +4374,6 @@
"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"
@@ -4039,6 +4438,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postprocessing": {
"version": "6.39.0",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz",
"integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==",
"license": "Zlib",
"peer": true,
"peerDependencies": {
"three": ">= 0.168.0 < 0.184.0"
}
},
"node_modules/potpack": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4049,6 +4464,16 @@
"node": ">= 0.8.0"
}
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -4759,6 +5184,21 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.13",
"react-dom": ">=16.13"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
@@ -4768,6 +5208,15 @@
"lodash": "^4.0.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -4869,7 +5318,6 @@
"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"
@@ -4882,7 +5330,6 @@
"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"
@@ -4904,6 +5351,32 @@
"node": ">=0.10.0"
}
},
"node_modules/stats-gl": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
"license": "MIT",
"dependencies": {
"@types/three": "*",
"three": "^0.170.0"
},
"peerDependencies": {
"@types/three": "*",
"three": "*"
}
},
"node_modules/stats-gl/node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/stats.js": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
"license": "MIT"
},
"node_modules/string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -4942,6 +5415,15 @@
"node": ">=8"
}
},
"node_modules/suspend-react": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
"integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0"
}
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
@@ -4955,6 +5437,45 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
"license": "MIT",
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/three-stdlib": {
"version": "2.36.1",
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
"license": "MIT",
"dependencies": {
"@types/draco3d": "^1.4.0",
"@types/offscreencanvas": "^2019.6.4",
"@types/webxr": "^0.5.2",
"draco3d": "^1.4.1",
"fflate": "^0.6.9",
"potpack": "^1.0.1"
},
"peerDependencies": {
"three": ">=0.128.0"
}
},
"node_modules/three-stdlib/node_modules/fflate": {
"version": "0.6.10",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
"license": "MIT"
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -4993,6 +5514,36 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/troika-three-text": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
"troika-three-utils": "^0.52.4",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-three-utils": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -5012,6 +5563,43 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-rat": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
"license": "MIT",
"dependencies": {
"zustand": "^4.3.2"
}
},
"node_modules/tunnel-rat/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5121,6 +5709,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
@@ -5209,11 +5806,21 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
},
"node_modules/webgl-sdf-generator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"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"
@@ -5293,6 +5900,35 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -12,6 +12,9 @@
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"antd": "^5.29.3",
"axios": "^1.14.0",
"dayjs": "^1.11.20",
@@ -19,13 +22,15 @@
"echarts-for-react": "^3.0.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",

View File

@@ -9,7 +9,10 @@ import Analysis from './pages/Analysis';
import Alarms from './pages/Alarms';
import Carbon from './pages/Carbon';
import Reports from './pages/Reports';
import Devices from './pages/Devices';
import SystemManagement from './pages/System';
import BigScreen from './pages/BigScreen';
import BigScreen3D from './pages/BigScreen3D';
import { isLoggedIn } from './utils/auth';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -25,9 +28,12 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/bigscreen" element={<BigScreen />} />
<Route path="/bigscreen-3d" element={<BigScreen3D />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="monitoring" element={<Monitoring />} />
<Route path="devices" element={<Devices />} />
<Route path="analysis" element={<Analysis />} />
<Route path="alarms" element={<Alarms />} />
<Route path="carbon" element={<Carbon />} />

View File

@@ -4,7 +4,7 @@ import {
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
ThunderboltOutlined,
ThunderboltOutlined, AppstoreOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { getUser, removeToken } from '../utils/auth';
@@ -15,6 +15,7 @@ const { Text } = Typography;
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '能源总览' },
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
{ key: '/devices', icon: <AppstoreOutlined />, label: '设备管理' },
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },

View File

@@ -0,0 +1,91 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
import AnimatedNumber from './AnimatedNumber';
interface Props {
alarmEvents: any[];
alarmStats: any;
}
export default function AlarmCard({ alarmEvents, alarmStats }: Props) {
const activeCount = alarmStats?.active_count ?? 0;
const weeklyTrend = alarmStats?.weekly_trend ?? [3, 5, 2, 8, 4, 6, 1];
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const trendOption = {
grid: { left: 30, right: 8, top: 8, bottom: 20 },
xAxis: {
type: 'category' as const,
data: weekDays,
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
axisTick: { show: false },
},
yAxis: {
type: 'value' as const,
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
},
series: [{
type: 'bar',
data: weeklyTrend,
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: '#ff8c00' },
{ offset: 1, color: 'rgba(255, 140, 0, 0.2)' },
],
} as any,
borderRadius: [2, 2, 0, 0],
},
barWidth: '50%',
}],
tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } },
};
const getSeverityClass = (severity: string) => {
if (severity === 'critical' || severity === 'high') return styles.alarmSeverityCritical;
if (severity === 'warning' || severity === 'medium') return styles.alarmSeverityWarning;
return styles.alarmSeverityInfo;
};
const formatTime = (ts: string) => {
if (!ts) return '';
const d = new Date(ts);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
};
return (
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<div>
<span className={styles.statLabel}></span>
<div>
<AnimatedNumber value={activeCount} className={styles.bigNumberRed} />
<span className={styles.unit}> </span>
</div>
</div>
</div>
<div className={styles.alarmList}>
{(alarmEvents ?? []).slice(0, 5).map((alarm: any, idx: number) => (
<div key={alarm.id ?? idx} className={styles.alarmItem}>
<span className={getSeverityClass(alarm.severity ?? 'info')} />
<span className={styles.alarmMsg}>{alarm.message ?? alarm.description ?? '未知告警'}</span>
<span className={styles.alarmTime}>{formatTime(alarm.triggered_at ?? alarm.created_at)}</span>
</div>
))}
{(!alarmEvents || alarmEvents.length === 0) && (
<div style={{ color: 'rgba(224,232,240,0.3)', fontSize: 12, padding: '8px 0' }}></div>
)}
</div>
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 80 }}>
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
value: number;
duration?: number;
decimals?: number;
className?: string;
}
export default function AnimatedNumber({ value, duration = 1500, decimals = 0, className }: Props) {
const [display, setDisplay] = useState(0);
const rafRef = useRef<number>(0);
const startRef = useRef<number>(0);
const fromRef = useRef<number>(0);
useEffect(() => {
fromRef.current = display;
startRef.current = performance.now();
const animate = (now: number) => {
const elapsed = now - startRef.current;
const progress = Math.min(elapsed / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = fromRef.current + (value - fromRef.current) * eased;
setDisplay(current);
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate);
}
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, duration]);
return <span className={className}>{display.toFixed(decimals)}</span>;
}

View File

@@ -0,0 +1,139 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
import AnimatedNumber from './AnimatedNumber';
interface Props {
carbonOverview: any;
carbonTrend: any;
}
export default function CarbonCard({ carbonOverview, carbonTrend }: Props) {
const annualEmission = carbonOverview?.annual_emission ?? 0;
const annualReduction = carbonOverview?.annual_reduction ?? 0;
const neutralityRate = annualEmission > 0
? Math.min((annualReduction / annualEmission) * 100, 100)
: 0;
// Monthly trend
const months = carbonTrend?.months ?? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const emissionData = carbonTrend?.emission ?? [];
const reductionData = carbonTrend?.reduction ?? [];
const trendOption = {
grid: { left: 40, right: 12, top: 24, bottom: 24 },
legend: {
data: ['碳排放', '碳减排'],
textStyle: { color: 'rgba(224,232,240,0.6)', fontSize: 10 },
top: 0,
right: 8,
itemWidth: 12,
itemHeight: 8,
},
tooltip: {
trigger: 'axis' as const,
backgroundColor: 'rgba(6,30,62,0.9)',
borderColor: 'rgba(0,212,255,0.3)',
textStyle: { color: '#e0e8f0', fontSize: 12 },
},
xAxis: {
type: 'category' as const,
data: months,
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
axisTick: { show: false },
},
yAxis: {
type: 'value' as const,
name: 'kgCO2',
nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 },
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
},
series: [
{
name: '碳排放',
type: 'line',
data: emissionData,
smooth: true,
symbol: 'none',
lineStyle: { color: '#ff8c00', width: 2 },
areaStyle: { color: 'rgba(255, 140, 0, 0.08)' },
},
{
name: '碳减排',
type: 'line',
data: reductionData,
smooth: true,
symbol: 'none',
lineStyle: { color: '#00ff88', width: 2 },
areaStyle: { color: 'rgba(0, 255, 136, 0.08)' },
},
],
};
// Neutrality gauge
const gaugeOption = {
series: [{
type: 'gauge',
startAngle: 220,
endAngle: -40,
radius: '90%',
center: ['50%', '55%'],
min: 0,
max: 100,
progress: {
show: true,
width: 10,
itemStyle: {
color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757',
},
},
axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.1)']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
title: { show: false },
detail: {
fontSize: 18,
fontWeight: 700,
color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757',
offsetCenter: [0, '10%'],
formatter: '{value}%',
},
data: [{ value: neutralityRate.toFixed(1) }],
}],
};
return (
<div className={styles.card} style={{ flex: '0 0 auto' }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
<div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueOrange}>
<AnimatedNumber value={annualEmission} decimals={0} />
<span className={styles.unit}> kg</span>
</span>
</div>
<div className={styles.statItem} style={{ marginTop: 4 }}>
<span className={styles.statLabel}></span>
<span className={styles.statValueGreen}>
<AnimatedNumber value={annualReduction} decimals={0} />
<span className={styles.unit}> kg</span>
</span>
</div>
</div>
</div>
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 100 }}>
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useRef } from 'react';
import styles from '../styles.module.css';
interface Props {
realtime: any;
overview: any;
}
interface Particle {
x: number;
y: number;
progress: number;
speed: number;
pathIndex: number;
}
export default function EnergyFlowDiagram({ realtime, overview }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef<number>(0);
const gridPower = realtime?.grid_power ?? 0;
const pvPower = realtime?.pv_power ?? 0;
const totalPower = realtime?.total_power ?? 0;
const hpPower = realtime?.heatpump_power ?? 0;
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const resizeObserver = new ResizeObserver(() => {
const rect = container.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
});
resizeObserver.observe(container);
// Initialize particles
particlesRef.current = [];
for (let i = 0; i < 60; i++) {
particlesRef.current.push({
x: 0, y: 0,
progress: Math.random(),
speed: 0.002 + Math.random() * 0.003,
pathIndex: Math.floor(Math.random() * 4),
});
}
const animate = () => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Node positions
const nodes = {
grid: { x: w * 0.12, y: h * 0.32, label: '电网', color: '#ff8c00' },
pv: { x: w * 0.12, y: h * 0.68, label: '光伏', color: '#00ff88' },
building: { x: w * 0.5, y: h * 0.5, label: '建筑负载', color: '#00d4ff' },
heatpump: { x: w * 0.85, y: h * 0.38, label: '热泵', color: '#00d4ff' },
heating: { x: w * 0.85, y: h * 0.68, label: '供暖', color: '#ff4757' },
};
// Define paths: [from, to]
const paths = [
{ from: nodes.grid, to: nodes.building, color: '#ff8c00', value: gridPower },
{ from: nodes.pv, to: nodes.building, color: '#00ff88', value: pvPower },
{ from: nodes.building, to: nodes.heatpump, color: '#00d4ff', value: hpPower },
{ from: nodes.heatpump, to: nodes.heating, color: '#ff4757', value: hpPower * 3.5 },
];
// Draw paths
paths.forEach((path) => {
ctx.beginPath();
ctx.moveTo(path.from.x, path.from.y);
// Bezier curve
const mx = (path.from.x + path.to.x) / 2;
ctx.bezierCurveTo(mx, path.from.y, mx, path.to.y, path.to.x, path.to.y);
ctx.strokeStyle = path.color + '30';
ctx.lineWidth = 3;
ctx.stroke();
});
// Animate particles
particlesRef.current.forEach((p) => {
p.progress += p.speed;
if (p.progress > 1) {
p.progress = 0;
p.pathIndex = Math.floor(Math.random() * paths.length);
}
const path = paths[p.pathIndex];
if (!path) return;
const t = p.progress;
const mx = (path.from.x + path.to.x) / 2;
// Cubic bezier interpolation
const u = 1 - t;
const x = u * u * u * path.from.x + 3 * u * u * t * mx + 3 * u * t * t * mx + t * t * t * path.to.x;
const y = u * u * u * path.from.y + 3 * u * u * t * path.from.y + 3 * u * t * t * path.to.y + t * t * t * path.to.y;
const alpha = t < 0.1 ? t / 0.1 : t > 0.9 ? (1 - t) / 0.1 : 1;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = path.color;
ctx.globalAlpha = alpha * 0.9;
ctx.fill();
// Glow
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fillStyle = path.color;
ctx.globalAlpha = alpha * 0.2;
ctx.fill();
ctx.globalAlpha = 1;
});
// Draw nodes
Object.values(nodes).forEach((node) => {
// Node bg
ctx.beginPath();
const rw = 60, rh = 36, r = 8;
const nx = node.x - rw, ny = node.y - rh;
const nw = rw * 2, nh = rh * 2;
ctx.moveTo(nx + r, ny);
ctx.lineTo(nx + nw - r, ny);
ctx.quadraticCurveTo(nx + nw, ny, nx + nw, ny + r);
ctx.lineTo(nx + nw, ny + nh - r);
ctx.quadraticCurveTo(nx + nw, ny + nh, nx + nw - r, ny + nh);
ctx.lineTo(nx + r, ny + nh);
ctx.quadraticCurveTo(nx, ny + nh, nx, ny + nh - r);
ctx.lineTo(nx, ny + r);
ctx.quadraticCurveTo(nx, ny, nx + r, ny);
ctx.closePath();
ctx.fillStyle = 'rgba(6, 30, 62, 0.95)';
ctx.fill();
ctx.strokeStyle = node.color + '66';
ctx.lineWidth = 1.5;
ctx.stroke();
// Shadow glow
ctx.shadowColor = node.color;
ctx.shadowBlur = 12;
ctx.strokeStyle = node.color + '33';
ctx.stroke();
ctx.shadowBlur = 0;
// Label
ctx.fillStyle = 'rgba(224, 232, 240, 0.7)';
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(node.label, node.x, node.y - 8);
// Value
let val = '0 kW';
if (node.label === '电网') val = gridPower.toFixed(1) + ' kW';
else if (node.label === '光伏') val = pvPower.toFixed(1) + ' kW';
else if (node.label === '建筑负载') val = totalPower.toFixed(1) + ' kW';
else if (node.label === '热泵') val = hpPower.toFixed(1) + ' kW';
else if (node.label === '供暖') val = (hpPower * 3.5).toFixed(1) + ' kW';
ctx.fillStyle = node.color;
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText(val, node.x, node.y + 12);
});
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(rafRef.current);
resizeObserver.disconnect();
};
}, [gridPower, pvPower, totalPower, hpPower]);
return (
<div ref={containerRef} className={styles.energyFlowWrap}>
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
</div>
);
}

View File

@@ -0,0 +1,101 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
import AnimatedNumber from './AnimatedNumber';
interface Props {
data: any;
realtime: any;
}
export default function EnergyOverviewCard({ data, realtime }: Props) {
const selfUseRate = data?.self_consumption_rate ?? 0;
const gaugeOption = {
series: [{
type: 'gauge',
startAngle: 220,
endAngle: -40,
radius: '90%',
center: ['50%', '55%'],
min: 0,
max: 100,
splitNumber: 5,
progress: { show: true, width: 12, itemStyle: { color: '#00d4ff' } },
axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0, 212, 255, 0.15)']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
title: { show: false },
detail: {
fontSize: 22,
fontWeight: 700,
color: '#00d4ff',
offsetCenter: [0, '10%'],
formatter: '{value}%',
},
data: [{ value: selfUseRate.toFixed(1) }],
}],
};
return (
<div className={styles.card} style={{ flex: '0 0 auto' }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueCyan}>
<AnimatedNumber value={data?.energy_today ?? 0} decimals={1} />
<span className={styles.unit}> kWh</span>
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueGreen}>
<AnimatedNumber value={data?.pv_generation_today ?? 0} decimals={1} />
<span className={styles.unit}> kWh</span>
</span>
</div>
</div>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueOrange}>
<AnimatedNumber value={data?.grid_import_today ?? 0} decimals={1} />
<span className={styles.unit}> kWh</span>
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValue}>
<AnimatedNumber value={realtime?.total_power ?? 0} decimals={1} />
<span className={styles.unit}> kW</span>
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 100, height: 100 }}>
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
<div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValue}>
<AnimatedNumber value={data?.carbon_emission ?? 0} decimals={0} />
<span className={styles.unit}> kgCO2</span>
</span>
</div>
<div className={styles.statItem} style={{ marginTop: 6 }}>
<span className={styles.statLabel}></span>
<span className={styles.statValueGreen}>
<AnimatedNumber value={data?.carbon_reduction ?? 0} decimals={0} />
<span className={styles.unit}> kgCO2</span>
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
import AnimatedNumber from './AnimatedNumber';
interface Props {
realtime: any;
overview: any;
}
export default function HeatPumpCard({ realtime, overview }: Props) {
const hpPower = realtime?.heatpump_power ?? 0;
const cop = overview?.heatpump_cop ?? 3.5;
const todayConsumption = overview?.heatpump_consumption_today ?? 0;
const monthlyConsumption = overview?.heatpump_monthly_consumption ?? 0;
const operatingHours = overview?.heatpump_operating_hours ?? 0;
const copGaugeOption = {
series: [{
type: 'gauge',
startAngle: 220,
endAngle: -40,
radius: '90%',
center: ['50%', '55%'],
min: 0,
max: 6,
splitNumber: 3,
progress: {
show: true,
width: 10,
itemStyle: { color: cop >= 3 ? '#00ff88' : '#ff8c00' },
},
axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.12)']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
title: { show: false },
detail: {
fontSize: 20,
fontWeight: 700,
color: '#00ff88',
offsetCenter: [0, '10%'],
formatter: '{value}',
},
data: [{ value: cop.toFixed(2) }],
}],
};
return (
<div className={styles.card} style={{ flex: '0 0 auto' }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<div>
<div className={styles.statLabel}></div>
<span>
<AnimatedNumber value={hpPower} decimals={1} className={styles.bigNumberCyan} />
<span className={styles.unit}> kW</span>
</span>
</div>
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
<ReactECharts option={copGaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
COP
</div>
</div>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueCyan}>
<AnimatedNumber value={todayConsumption} decimals={1} />
<span className={styles.unit}> kWh</span>
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueCyan}>
<AnimatedNumber value={monthlyConsumption} decimals={0} />
<span className={styles.unit}> kWh</span>
</span>
</div>
</div>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValue}>
<AnimatedNumber value={operatingHours} decimals={1} />
<span className={styles.unit}> h</span>
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
interface Props {
loadData: any;
}
export default function LoadCurveCard({ loadData }: Props) {
const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`);
const values = loadData?.values ?? new Array(24).fill(0);
const peak = values.length ? Math.max(...values) : 0;
const valley = values.length ? Math.min(...values.filter((v: number) => v > 0)) || 0 : 0;
const avg = values.length ? values.reduce((a: number, b: number) => a + b, 0) / values.filter((v: number) => v > 0).length || 0 : 0;
const option = {
grid: { left: 40, right: 12, top: 30, bottom: 24 },
tooltip: {
trigger: 'axis' as const,
backgroundColor: 'rgba(6,30,62,0.9)',
borderColor: 'rgba(0,212,255,0.3)',
textStyle: { color: '#e0e8f0', fontSize: 12 },
},
xAxis: {
type: 'category' as const,
data: hours,
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)', interval: 3 },
axisTick: { show: false },
},
yAxis: {
type: 'value' as const,
name: 'kW',
nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 },
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
},
series: [{
type: 'line',
data: values,
smooth: true,
symbol: 'none',
lineStyle: { color: '#00d4ff', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 212, 255, 0.3)' },
{ offset: 1, color: 'rgba(0, 212, 255, 0.02)' },
],
} as any,
},
markLine: {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed' as const, width: 1 },
data: [
{ yAxis: peak, label: { formatter: '峰值 {c}kW', color: '#ff4757', fontSize: 10 }, lineStyle: { color: '#ff475740' } },
{ yAxis: avg, label: { formatter: '均值 {c}kW', color: '#ff8c00', fontSize: 10 }, lineStyle: { color: '#ff8c0040' } },
],
},
}],
};
return (
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div className={styles.statRow} style={{ marginBottom: 4 }}>
<div className={styles.statItem}>
<span className={styles.statLabel}> <span style={{ color: '#ff4757' }}>{peak.toFixed(1)} kW</span></span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}> <span style={{ color: '#00ff88' }}>{valley.toFixed(1)} kW</span></span>
</div>
</div>
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
<ReactECharts option={option} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import ReactECharts from 'echarts-for-react';
import styles from '../styles.module.css';
import AnimatedNumber from './AnimatedNumber';
interface Props {
realtime: any;
overview: any;
}
export default function PVCard({ realtime, overview }: Props) {
const pvPower = realtime?.pv_power ?? 0;
const todayGen = overview?.pv_generation_today ?? 0;
const monthlyGen = overview?.pv_monthly_generation ?? 0;
const selfUseRate = overview?.self_consumption_rate ?? 0;
// Donut for self-use ratio
const donutOption = {
series: [{
type: 'pie',
radius: ['60%', '80%'],
center: ['50%', '50%'],
silent: true,
label: { show: false },
data: [
{ value: selfUseRate, itemStyle: { color: '#00ff88' } },
{ value: 100 - selfUseRate, itemStyle: { color: 'rgba(0, 255, 136, 0.1)' } },
],
}],
};
// Monthly PV bar chart (mock 12 months)
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const monthlyData = overview?.monthly_pv_data ?? months.map(() => Math.round(Math.random() * 3000 + 1000));
const barOption = {
grid: { left: 35, right: 8, top: 8, bottom: 20 },
xAxis: {
type: 'category' as const,
data: months,
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
axisTick: { show: false },
},
yAxis: {
type: 'value' as const,
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
},
series: [{
type: 'bar',
data: monthlyData,
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: '#00ff88' },
{ offset: 1, color: 'rgba(0, 255, 136, 0.2)' },
],
} as any,
borderRadius: [2, 2, 0, 0],
},
barWidth: '50%',
}],
tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } },
};
return (
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
<div className={styles.cardTitle}></div>
<div className={styles.cardBody}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
<div>
<div className={styles.statLabel}></div>
<span className={styles.bigNumberGreen || styles.bigNumber}>
<AnimatedNumber value={pvPower} decimals={1} className={styles.bigNumber} />
<span className={styles.unit}> kW</span>
</span>
</div>
<div style={{ width: 64, height: 64, flexShrink: 0 }}>
<ReactECharts option={donutOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
<br/>
<span style={{ fontSize: 16, fontWeight: 700, color: '#00ff88' }}>{selfUseRate.toFixed(1)}%</span>
</div>
</div>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueGreen}>
<AnimatedNumber value={todayGen} decimals={1} />
<span className={styles.unit}> kWh</span>
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}></span>
<span className={styles.statValueGreen}>
<AnimatedNumber value={monthlyGen} decimals={0} />
<span className={styles.unit}> kWh</span>
</span>
</div>
</div>
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
<ReactECharts option={barOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import styles from './styles.module.css';
import EnergyOverviewCard from './components/EnergyOverviewCard';
import PVCard from './components/PVCard';
import HeatPumpCard from './components/HeatPumpCard';
import EnergyFlowDiagram from './components/EnergyFlowDiagram';
import LoadCurveCard from './components/LoadCurveCard';
import AlarmCard from './components/AlarmCard';
import CarbonCard from './components/CarbonCard';
import AnimatedNumber from './components/AnimatedNumber';
import {
getDashboardOverview,
getRealtimeData,
getLoadCurve,
getAlarmEvents,
getAlarmStats,
getCarbonOverview,
getCarbonTrend,
getDeviceStats,
} from '../../services/api';
export default function BigScreen() {
const [clock, setClock] = useState(new Date());
const [overview, setOverview] = useState<any>(null);
const [realtime, setRealtime] = useState<any>(null);
const [loadData, setLoadData] = useState<any>(null);
const [alarmEvents, setAlarmEvents] = useState<any[]>([]);
const [alarmStats, setAlarmStats] = useState<any>(null);
const [carbonOverview, setCarbonOverview] = useState<any>(null);
const [carbonTrend, setCarbonTrend] = useState<any>(null);
const [deviceStats, setDeviceStats] = useState<any>(null);
const timerRef = useRef<any>(null);
// Clock update every second
useEffect(() => {
const t = setInterval(() => setClock(new Date()), 1000);
return () => clearInterval(t);
}, []);
// Fetch all data
const fetchAll = useCallback(async () => {
try {
const results = await Promise.allSettled([
getDashboardOverview(),
getRealtimeData(),
getLoadCurve(24),
getAlarmEvents({ limit: 5 }),
getAlarmStats(),
getCarbonOverview(),
getCarbonTrend(365),
getDeviceStats(),
]);
const get = (i: number) => {
const r = results[i];
if (r.status === 'fulfilled') {
const val = r.value as any;
return val?.data ?? val;
}
return null;
};
if (get(0)) setOverview(get(0));
if (get(1)) setRealtime(get(1));
if (get(2)) setLoadData(get(2));
if (get(3)) {
const alarms = get(3);
setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []);
}
if (get(4)) setAlarmStats(get(4));
if (get(5)) setCarbonOverview(get(5));
if (get(6)) setCarbonTrend(get(6));
if (get(7)) setDeviceStats(get(7));
} catch (e) {
console.error('BigScreen fetch error:', e);
}
}, []);
// Initial fetch + polling every 15s
useEffect(() => {
fetchAll();
timerRef.current = setInterval(fetchAll, 15000);
return () => clearInterval(timerRef.current);
}, [fetchAll]);
const formatDate = (d: Date) => {
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return `${y}${m}${day}${weekdays[d.getDay()]}`;
};
const formatTime = (d: Date) => {
return d.toLocaleTimeString('zh-CN', { hour12: false });
};
const totalDevices = deviceStats?.total ?? 0;
const onlineDevices = deviceStats?.online ?? 0;
const offlineDevices = deviceStats?.offline ?? 0;
const alarmDevices = deviceStats?.alarm_count ?? alarmStats?.active_count ?? 0;
return (
<div className={styles.container}>
{/* Header */}
<div className={styles.header}>
<span className={styles.headerDate}>{formatDate(clock)}</span>
<h1 className={styles.headerTitle}></h1>
<span className={styles.headerTime}>{formatTime(clock)}</span>
</div>
{/* Main 3-column grid */}
<div className={styles.mainGrid}>
{/* Left Column */}
<div className={styles.column}>
<EnergyOverviewCard data={overview} realtime={realtime} />
<PVCard realtime={realtime} overview={overview} />
<HeatPumpCard realtime={realtime} overview={overview} />
</div>
{/* Center Column */}
<div className={styles.column}>
<div className={styles.centerCard} style={{ flex: 1 }}>
<div className={styles.cardTitle}></div>
<EnergyFlowDiagram realtime={realtime} overview={overview} />
</div>
<div className={styles.card} style={{ flex: '0 0 auto', padding: '10px 16px' }}>
<div className={styles.cardTitle}></div>
<div className={styles.deviceStatusBar}>
<div className={styles.deviceStatusItem}>
<span className={styles.statusDotCyan} />
<span className={styles.statusLabel}></span>
<AnimatedNumber value={totalDevices} className={styles.statusCount} />
</div>
<div className={styles.deviceStatusItem}>
<span className={styles.statusDotGreen} />
<span className={styles.statusLabel}>线</span>
<AnimatedNumber value={onlineDevices} className={styles.statusCount} />
</div>
<div className={styles.deviceStatusItem}>
<span className={styles.statusDotRed} />
<span className={styles.statusLabel}>线</span>
<AnimatedNumber value={offlineDevices} className={styles.statusCount} />
</div>
<div className={styles.deviceStatusItem}>
<span className={styles.statusDotOrange} />
<span className={styles.statusLabel}></span>
<AnimatedNumber value={alarmDevices} className={styles.statusCount} />
</div>
</div>
</div>
</div>
{/* Right Column */}
<div className={styles.column}>
<LoadCurveCard loadData={loadData} />
<AlarmCard alarmEvents={alarmEvents} alarmStats={alarmStats} />
<CarbonCard carbonOverview={carbonOverview} carbonTrend={carbonTrend} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
/* Big Screen Visualization Dashboard - Dark Theme */
.container {
width: 100vw;
height: 100vh;
background: #0a1628;
background-image:
radial-gradient(circle at 1px 1px, rgba(0, 212, 255, 0.06) 1px, transparent 0);
background-size: 40px 40px;
color: #e0e8f0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header */
.header {
height: 72px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: linear-gradient(180deg, rgba(0, 212, 255, 0.12) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
flex-shrink: 0;
}
.headerTitle {
font-size: 28px;
font-weight: 700;
letter-spacing: 6px;
background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 4s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.headerTime {
position: absolute;
right: 32px;
font-size: 16px;
color: rgba(0, 212, 255, 0.8);
letter-spacing: 2px;
}
.headerDate {
position: absolute;
left: 32px;
font-size: 14px;
color: rgba(0, 212, 255, 0.6);
letter-spacing: 1px;
}
/* Main Grid */
.mainGrid {
flex: 1;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 12px;
padding: 12px;
min-height: 0;
}
.column {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
/* Cards */
.card {
background: rgba(6, 30, 62, 0.85);
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 8px;
padding: 14px 16px;
position: relative;
overflow: hidden;
box-shadow:
0 0 12px rgba(0, 212, 255, 0.08),
inset 0 1px 0 rgba(0, 212, 255, 0.1);
display: flex;
flex-direction: column;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
}
.cardTitle {
font-size: 15px;
font-weight: 600;
color: #00d4ff;
margin-bottom: 12px;
padding-left: 10px;
border-left: 3px solid #00d4ff;
letter-spacing: 2px;
flex-shrink: 0;
}
.cardBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Big numbers */
.bigNumber {
font-size: 32px;
font-weight: 700;
color: #00ff88;
line-height: 1;
}
.bigNumberCyan {
font-size: 32px;
font-weight: 700;
color: #00d4ff;
line-height: 1;
}
.bigNumberOrange {
font-size: 32px;
font-weight: 700;
color: #ff8c00;
line-height: 1;
}
.bigNumberRed {
font-size: 24px;
font-weight: 700;
color: #ff4757;
line-height: 1;
}
.unit {
font-size: 13px;
font-weight: 400;
color: rgba(224, 232, 240, 0.6);
margin-left: 4px;
}
/* Stat grid rows */
.statRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.statItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.statLabel {
font-size: 12px;
color: rgba(224, 232, 240, 0.5);
}
.statValue {
font-size: 18px;
font-weight: 600;
color: #e0e8f0;
}
.statValueCyan {
font-size: 18px;
font-weight: 600;
color: #00d4ff;
}
.statValueGreen {
font-size: 18px;
font-weight: 600;
color: #00ff88;
}
.statValueOrange {
font-size: 18px;
font-weight: 600;
color: #ff8c00;
}
/* Center - Energy Flow */
.centerCard {
background: rgba(6, 30, 62, 0.85);
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 8px;
padding: 16px;
flex: 1;
position: relative;
overflow: hidden;
box-shadow:
0 0 12px rgba(0, 212, 255, 0.08),
inset 0 1px 0 rgba(0, 212, 255, 0.1);
display: flex;
flex-direction: column;
}
.centerCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
}
/* Device status bar */
.deviceStatusBar {
display: flex;
gap: 24px;
justify-content: center;
padding: 8px 0;
flex-shrink: 0;
}
.deviceStatusItem {
display: flex;
align-items: center;
gap: 8px;
}
.statusDot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.statusDotGreen {
composes: statusDot;
background: #00ff88;
box-shadow: 0 0 8px rgba(0, 255, 136, 0.5);
}
.statusDotRed {
composes: statusDot;
background: #ff4757;
box-shadow: 0 0 8px rgba(255, 71, 87, 0.5);
}
.statusDotOrange {
composes: statusDot;
background: #ff8c00;
box-shadow: 0 0 8px rgba(255, 140, 0, 0.5);
}
.statusDotCyan {
composes: statusDot;
background: #00d4ff;
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
}
.statusLabel {
font-size: 13px;
color: rgba(224, 232, 240, 0.6);
}
.statusCount {
font-size: 18px;
font-weight: 700;
color: #e0e8f0;
}
/* Alarm list */
.alarmList {
flex: 1;
overflow: hidden;
}
.alarmItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(0, 212, 255, 0.1);
font-size: 12px;
}
.alarmSeverity {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.alarmSeverityCritical {
composes: alarmSeverity;
background: #ff4757;
box-shadow: 0 0 6px rgba(255, 71, 87, 0.6);
}
.alarmSeverityWarning {
composes: alarmSeverity;
background: #ff8c00;
box-shadow: 0 0 6px rgba(255, 140, 0, 0.6);
}
.alarmSeverityInfo {
composes: alarmSeverity;
background: #00d4ff;
box-shadow: 0 0 6px rgba(0, 212, 255, 0.6);
}
.alarmMsg {
flex: 1;
color: rgba(224, 232, 240, 0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.alarmTime {
color: rgba(224, 232, 240, 0.4);
flex-shrink: 0;
font-size: 11px;
}
/* Progress ring */
.progressRing {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
/* Chart wrapper */
.chartWrap {
flex: 1;
min-height: 0;
}
/* Energy Flow SVG area */
.energyFlowWrap {
flex: 1;
min-height: 0;
position: relative;
}
/* Animated flow particles */
@keyframes flowRight {
0% { offset-distance: 0%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { offset-distance: 100%; opacity: 0; }
}
@keyframes flowLeft {
0% { offset-distance: 100%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { offset-distance: 0%; opacity: 0; }
}
@keyframes pulse {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
.pulseAnim {
animation: pulse 2s ease-in-out infinite;
}
/* Energy flow node */
.flowNode {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 140px;
height: 100px;
background: rgba(6, 30, 62, 0.95);
border: 1.5px solid rgba(0, 212, 255, 0.4);
border-radius: 12px;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.15);
z-index: 2;
}
.flowNodeLabel {
font-size: 13px;
color: rgba(224, 232, 240, 0.7);
margin-bottom: 4px;
}
.flowNodeValue {
font-size: 22px;
font-weight: 700;
color: #00d4ff;
}
.flowNodeUnit {
font-size: 11px;
color: rgba(224, 232, 240, 0.5);
}
/* Gauge display */
.gaugeWrap {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.gaugeLabel {
font-size: 12px;
color: rgba(224, 232, 240, 0.5);
}

View File

@@ -0,0 +1,130 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import { Html } from '@react-three/drei';
import { BUILDINGS, COLORS } from '../constants';
interface BuildingsProps {
detailMode?: boolean;
onBuildingClick?: (building: string) => void;
}
function WindowGrid({ width, height, depth }: { width: number; height: number; depth: number }) {
const windows = useMemo(() => {
const cols = 4;
const rows = 3;
const winW = 1.5;
const winH = 0.8;
const winD = 0.05;
const gapX = (width - cols * winW) / (cols + 1);
const gapY = (height - rows * winH) / (rows + 1);
const items: { pos: [number, number, number] }[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = -width / 2 + gapX + winW / 2 + c * (winW + gapX);
const y = -height / 2 + gapY + winH / 2 + r * (winH + gapY);
items.push({ pos: [x, y, depth / 2 + winD / 2] });
}
}
return items;
}, [width, height, depth]);
return (
<group>
{windows.map((w, i) => (
<mesh key={i} position={w.pos}>
<boxGeometry args={[1.5, 0.8, 0.05]} />
<meshStandardMaterial
color="#ffcc66"
emissive="#ffcc66"
emissiveIntensity={0.3}
transparent
opacity={0.6}
/>
</mesh>
))}
</group>
);
}
function Building({
label,
position,
size,
opacity,
onClick,
}: {
label: string;
position: [number, number, number];
size: [number, number, number];
opacity: number;
onClick?: () => void;
}) {
const [w, h, d] = size;
const edgesGeo = useMemo(() => {
const box = new THREE.BoxGeometry(w, h, d);
return new THREE.EdgesGeometry(box);
}, [w, h, d]);
const labelStyle: React.CSSProperties = {
fontSize: '13px',
color: COLORS.text,
background: 'rgba(6, 30, 62, 0.8)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 212, 255, 0.2)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
};
return (
<group position={position} onClick={onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined}>
{/* Main body */}
<mesh castShadow receiveShadow>
<boxGeometry args={[w, h, d]} />
<meshStandardMaterial
color={COLORS.buildingBase}
transparent
opacity={opacity}
/>
</mesh>
{/* Edge highlight */}
<lineSegments geometry={edgesGeo}>
<lineBasicMaterial color="#00d4ff" transparent opacity={0.3} />
</lineSegments>
{/* Windows on front face */}
<WindowGrid width={w} height={h} depth={d} />
{/* Label */}
<Html position={[0, h / 2 + 1.5, 0]} center>
<div style={labelStyle}>{label}</div>
</Html>
</group>
);
}
export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) {
const opacity = detailMode ? 0.15 : 0.85;
return (
<group>
<Building
label={BUILDINGS.east.label}
position={[...BUILDINGS.east.position]}
size={[...BUILDINGS.east.size]}
opacity={opacity}
onClick={onBuildingClick ? () => onBuildingClick('east') : undefined}
/>
<Building
label={BUILDINGS.west.label}
position={[...BUILDINGS.west.position]}
size={[...BUILDINGS.west.size]}
opacity={opacity}
onClick={onBuildingClick ? () => onBuildingClick('west') : undefined}
/>
</group>
);
}

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { EffectComposer, Bloom } from '@react-three/postprocessing';
import { CAMERA, COLORS } from '../constants';
import { useCameraAnimation } from '../hooks/useCameraAnimation';
import SceneEnvironment from './SceneEnvironment';
import Ground from './Ground';
import Buildings from './Buildings';
import PVPanels from './PVPanels';
import HeatPumps from './HeatPumps';
import DeviceMarkers from './DeviceMarkers';
import EnergyParticles from './EnergyParticles';
import DeviceDetailView from './DeviceDetailView';
interface CampusSceneProps {
devices: Array<any>;
energyFlow: { nodes: any[]; links: any[] };
realtimeData: any | null;
selectedDevice: any | null;
selectedDevicePosition: [number, number, number] | null;
detailRealtimeData: Record<string, { value: number; unit: string }> | null;
hoveredDeviceId: number | null;
viewMode: 'campus' | 'device-detail';
onDeviceSelect: (device: any) => void;
onDeviceHover: (id: number | null) => void;
}
// Inner component: handles camera animation from within Canvas context
function CameraController({
selectedDevicePosition,
viewMode,
}: {
selectedDevicePosition: [number, number, number] | null;
viewMode: 'campus' | 'device-detail';
}) {
const { animateTo, resetToOverview } = useCameraAnimation();
const prevViewMode = useRef(viewMode);
useEffect(() => {
if (viewMode === 'device-detail' && selectedDevicePosition) {
const [x, y, z] = selectedDevicePosition;
animateTo([x + 8, y + 6, z + 10], [x, y, z], 1.5);
} else if (viewMode === 'campus' && prevViewMode.current === 'device-detail') {
resetToOverview();
}
prevViewMode.current = viewMode;
}, [viewMode, selectedDevicePosition, animateTo, resetToOverview]);
return null;
}
// Inner scene content (must be inside Canvas to use R3F hooks)
function SceneContent({
devices,
energyFlow,
realtimeData,
selectedDevice,
selectedDevicePosition,
detailRealtimeData,
hoveredDeviceId,
viewMode,
onDeviceSelect,
onDeviceHover,
}: CampusSceneProps) {
const detailMode = viewMode === 'device-detail';
const { pvDevices, hpDevices, markerDevices } = useMemo(() => {
const pv: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }> = [];
const hp: Array<{ id: number; code: string; status: string; power?: number }> = [];
const markers: Array<{
id: number;
code: string;
device_type: string;
name: string;
status: string;
primaryValue?: string;
}> = [];
devices.forEach((d: any) => {
const type: string = d.device_type ?? '';
const code: string = d.code ?? '';
if (type === 'pv_inverter') {
pv.push({ id: d.id, code, status: d.status, power: d.power, rated_power: d.rated_power });
} else if (type === 'heat_pump') {
hp.push({ id: d.id, code, status: d.status, power: d.power });
} else if (['meter', 'sensor', 'heat_meter'].includes(type)) {
markers.push({
id: d.id,
code,
device_type: type,
name: d.name ?? code,
status: d.status,
primaryValue: d.primaryValue,
});
}
});
return { pvDevices: pv, hpDevices: hp, markerDevices: markers };
}, [devices]);
return (
<>
<CameraController
selectedDevicePosition={selectedDevicePosition}
viewMode={viewMode}
/>
<SceneEnvironment />
<Ground />
<Buildings detailMode={detailMode} />
<PVPanels
devices={pvDevices}
hoveredId={hoveredDeviceId}
onHover={onDeviceHover}
onClick={onDeviceSelect}
detailMode={detailMode}
/>
<HeatPumps
devices={hpDevices}
hoveredId={hoveredDeviceId}
onHover={onDeviceHover}
onClick={onDeviceSelect}
detailMode={detailMode}
/>
<DeviceMarkers
devices={markerDevices}
hoveredId={hoveredDeviceId}
onHover={onDeviceHover}
onClick={onDeviceSelect}
detailMode={detailMode}
/>
<EnergyParticles
energyFlow={energyFlow}
realtimeData={realtimeData}
/>
{viewMode === 'device-detail' && selectedDevice && selectedDevicePosition && (
<DeviceDetailView
device={selectedDevice}
position={selectedDevicePosition}
realtimeData={detailRealtimeData}
/>
)}
<OrbitControls
enableDamping
dampingFactor={0.05}
maxPolarAngle={Math.PI / 2.2}
minDistance={5}
maxDistance={80}
/>
<EffectComposer>
<Bloom luminanceThreshold={0.5} intensity={0.6} />
</EffectComposer>
</>
);
}
export default function CampusScene(props: CampusSceneProps) {
return (
<Canvas
camera={{
position: CAMERA.campusPosition as unknown as [number, number, number],
fov: CAMERA.fov,
near: CAMERA.near,
far: CAMERA.far,
}}
shadows="soft"
style={{ background: COLORS.background }}
gl={{ antialias: true, alpha: false }}
>
<SceneContent {...props} />
</Canvas>
);
}

View File

@@ -0,0 +1,490 @@
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
interface DeviceDetailViewProps {
device: {
id: number;
name: string;
code: string;
device_type: string;
status: string;
rated_power?: number;
};
position: [number, number, number];
realtimeData: Record<string, { value: number; unit: string }> | null;
}
const overlayStyle: React.CSSProperties = {
background: 'rgba(6, 30, 62, 0.95)',
border: '1px solid rgba(0, 212, 255, 0.3)',
padding: 12,
borderRadius: 8,
color: '#e0e8f0',
fontSize: 12,
minWidth: 200,
pointerEvents: 'none' as const,
fontFamily: 'monospace',
};
// ─── PV Inverter Detail ─────────────────────────────────────────────
function PVDetail({
getValue,
}: {
getValue: (key: string) => number;
}) {
const groupRef = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (groupRef.current) {
groupRef.current.rotation.y += 0.1 * delta;
}
});
const panels = useMemo(() => {
const items: { pos: [number, number, number] }[] = [];
const cols = 5;
const rows = 3;
const pw = 2.5;
const ph = 1.2;
const depth = 0.08;
const gap = 0.3;
const totalW = cols * pw + (cols - 1) * gap;
const totalD = rows * ph + (rows - 1) * gap;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = -totalW / 2 + pw / 2 + c * (pw + gap);
const z = -totalD / 2 + ph / 2 + r * (ph + gap);
items.push({ pos: [x, 0, z] });
}
}
return { items, totalW, depth, pw, ph };
}, []);
const tilt = Math.PI / 6; // 30 degrees
return (
<group ref={groupRef} position={[0, 2, 0]}>
{/* Scale the whole panel array 1.5x */}
<group scale={1.5}>
{panels.items.map((p, i) => (
<mesh key={i} position={p.pos} rotation={[-tilt, 0, 0]} castShadow>
<boxGeometry args={[panels.pw, panels.depth, panels.ph]} />
<meshStandardMaterial
color="#1e3a5f"
metalness={0.85}
roughness={0.2}
emissive="#004488"
emissiveIntensity={0.3}
/>
</mesh>
))}
{/* Mounting rails */}
<mesh position={[0, -0.15, -1]} rotation={[-tilt, 0, 0]}>
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
</mesh>
<mesh position={[0, -0.15, 1]} rotation={[-tilt, 0, 0]}>
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
</mesh>
</group>
{/* HTML overlay block diagram + live data */}
<Html position={[10, 1, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ marginBottom: 8, whiteSpace: 'pre', lineHeight: 1.4, fontSize: 11 }}>
{`┌─────────┐ ┌──────┐ ┌─────────┐
│ DC Input │ → │ MPPT │ → │ AC Output│
└─────────┘ └──────┘ └─────────┘`}
</div>
<div style={{ borderTop: '1px solid rgba(0,212,255,0.2)', paddingTop: 6 }}>
<div>DC电压: {getValue('dc_voltage').toFixed(1)} V</div>
<div>AC电压: {getValue('ac_voltage').toFixed(1)} V</div>
<div>: {getValue('power').toFixed(1)} kW</div>
<div>: {getValue('temperature').toFixed(1)} </div>
</div>
</div>
</Html>
</group>
);
}
// ─── Heat Pump Detail ───────────────────────────────────────────────
function HeatPumpDetail({
getValue,
}: {
getValue: (key: string) => number;
}) {
const particlesRef = useRef<THREE.Group>(null);
const fanRef = useRef<THREE.Group>(null);
// Refrigerant circulation path
const { curve, particleCount } = useMemo(() => {
const points = [
new THREE.Vector3(0, 0, 0), // compressor center
new THREE.Vector3(1, 0, 0), // condenser
new THREE.Vector3(1, -0.8, 0), // bottom right
new THREE.Vector3(0, -0.9, 0), // expansion valve
new THREE.Vector3(-1, -0.8, 0), // bottom left
new THREE.Vector3(-1, 0, 0), // evaporator
new THREE.Vector3(-0.5, 0.4, 0), // top left
new THREE.Vector3(0, 0.4, 0), // top center back to compressor
];
return {
curve: new THREE.CatmullRomCurve3(points, true),
particleCount: 12,
};
}, []);
useFrame((state, delta) => {
// Animate particles along path
if (particlesRef.current) {
const t = state.clock.elapsedTime;
particlesRef.current.children.forEach((child, i) => {
const offset = i / particleCount;
const pos = curve.getPointAt((t * 0.15 + offset) % 1);
child.position.copy(pos);
});
}
// Spin fan
if (fanRef.current) {
fanRef.current.rotation.y += 3 * delta;
}
});
return (
<group>
{/* Main housing transparent cutaway */}
<mesh>
<boxGeometry args={[3, 2.2, 2.2]} />
<meshStandardMaterial
color="#2a4a6a"
transparent
opacity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
{/* Compressor */}
<mesh position={[0, 0, 0]}>
<cylinderGeometry args={[0.4, 0.4, 0.8, 16]} />
<meshStandardMaterial color="#4488cc" metalness={0.5} roughness={0.3} />
</mesh>
{/* Evaporator (left) */}
<mesh position={[-1, 0, 0]}>
<boxGeometry args={[0.1, 1.5, 1.5]} />
<meshStandardMaterial color="#00aaff" metalness={0.3} roughness={0.5} />
</mesh>
{/* Condenser (right) */}
<mesh position={[1, 0, 0]}>
<boxGeometry args={[0.1, 1.5, 1.5]} />
<meshStandardMaterial color="#ff6644" metalness={0.3} roughness={0.5} />
</mesh>
{/* Expansion valve */}
<mesh position={[0, -0.9, 0]}>
<sphereGeometry args={[0.15, 16, 16]} />
<meshStandardMaterial color="#ffaa00" metalness={0.4} roughness={0.4} />
</mesh>
{/* Refrigerant particles */}
<group ref={particlesRef}>
{Array.from({ length: particleCount }).map((_, i) => (
<mesh key={i}>
<sphereGeometry args={[0.08, 8, 8]} />
<meshStandardMaterial
color="#00d4ff"
emissive="#00d4ff"
emissiveIntensity={0.8}
/>
</mesh>
))}
</group>
{/* Fan on top */}
<group ref={fanRef} position={[0, 1.3, 0]} rotation={[Math.PI / 2, 0, 0]}>
{[0, 1, 2].map((i) => (
<mesh key={i} rotation={[0, 0, (i * Math.PI * 2) / 3]}>
<boxGeometry args={[0.08, 0.6, 0.02]} />
<meshStandardMaterial color="#aaaaaa" metalness={0.5} />
</mesh>
))}
{/* Fan hub */}
<mesh>
<cylinderGeometry args={[0.08, 0.08, 0.05, 12]} />
<meshStandardMaterial color="#666666" metalness={0.6} />
</mesh>
</group>
{/* HTML overlay */}
<Html position={[3, 0, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#00d4ff' }}></div>
<div>: {getValue('power').toFixed(1)} kW</div>
<div>COP: {getValue('cop').toFixed(2)}</div>
<div>: {getValue('inlet_temp').toFixed(1)} </div>
<div>: {getValue('outlet_temp').toFixed(1)} </div>
<div>: {getValue('flow_rate').toFixed(2)} m³/h</div>
<div>: {getValue('outdoor_temp').toFixed(1)} </div>
</div>
</Html>
</group>
);
}
// ─── Meter Detail ───────────────────────────────────────────────────
function MeterDetail({
getValue,
}: {
getValue: (key: string) => number;
}) {
const needleRef = useRef<THREE.Mesh>(null);
useFrame(() => {
if (needleRef.current) {
const power = getValue('power');
const angle = (power / 150) * Math.PI - Math.PI / 2;
needleRef.current.rotation.z = angle;
}
});
return (
<group>
{/* Body */}
<mesh>
<boxGeometry args={[1.5, 2, 0.5]} />
<meshStandardMaterial color="#ff8c00" metalness={0.4} roughness={0.5} />
</mesh>
{/* Screen */}
<mesh position={[0, 0.4, 0.27]}>
<boxGeometry args={[1, 0.6, 0.02]} />
<meshStandardMaterial
color="#1a1a1a"
emissive="#003300"
emissiveIntensity={0.3}
/>
</mesh>
{/* Dial face */}
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
<meshStandardMaterial color="#111111" metalness={0.3} />
</mesh>
{/* Needle */}
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
<boxGeometry args={[0.02, 0.35, 0.02]} />
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.5} />
</mesh>
{/* HTML overlay */}
<Html position={[2, 0, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#ff8c00' }}></div>
<div>: {getValue('power').toFixed(1)} kW</div>
<div>: {getValue('voltage').toFixed(1)} V</div>
<div>: {getValue('current').toFixed(2)} A</div>
<div>: {getValue('power_factor').toFixed(3)}</div>
</div>
</Html>
</group>
);
}
// ─── Heat Meter Detail ──────────────────────────────────────────────
function HeatMeterDetail({
getValue,
}: {
getValue: (key: string) => number;
}) {
const needleRef = useRef<THREE.Mesh>(null);
useFrame(() => {
if (needleRef.current) {
const power = getValue('heat_power');
const angle = (power / 200) * Math.PI - Math.PI / 2;
needleRef.current.rotation.z = angle;
}
});
return (
<group>
{/* Body */}
<mesh>
<boxGeometry args={[1.5, 2, 0.5]} />
<meshStandardMaterial color="#cc3366" metalness={0.4} roughness={0.5} />
</mesh>
{/* Display screen */}
<mesh position={[0, 0.4, 0.27]}>
<boxGeometry args={[1, 0.6, 0.02]} />
<meshStandardMaterial color="#1a1a1a" emissive="#220011" emissiveIntensity={0.3} />
</mesh>
{/* Dial */}
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
<meshStandardMaterial color="#111111" metalness={0.3} />
</mesh>
{/* Needle */}
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
<boxGeometry args={[0.02, 0.35, 0.02]} />
<meshStandardMaterial color="#ff3366" emissive="#ff3366" emissiveIntensity={0.5} />
</mesh>
{/* HTML overlay */}
<Html position={[2, 0, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#cc3366' }}></div>
<div>: {getValue('heat_power').toFixed(1)} kW</div>
<div>: {getValue('flow_rate').toFixed(2)} m³/h</div>
<div>: {getValue('supply_temp').toFixed(1)} </div>
<div>: {getValue('return_temp').toFixed(1)} </div>
<div>: {getValue('cumulative_heat').toFixed(3)} GJ</div>
</div>
</Html>
</group>
);
}
// ─── Sensor Detail ──────────────────────────────────────────────────
function SensorDetail({
getValue,
}: {
getValue: (key: string) => number;
}) {
const sphereRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
useFrame((state, delta) => {
// Pulsing glow
if (sphereRef.current) {
const mat = sphereRef.current.material as THREE.MeshStandardMaterial;
mat.emissiveIntensity = 0.3 + 0.3 * Math.sin(state.clock.elapsedTime * 2);
}
// Rotating ring
if (ringRef.current) {
ringRef.current.rotation.y += 0.8 * delta;
}
});
return (
<group>
{/* Sensor sphere */}
<mesh ref={sphereRef}>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial
color="#a78bfa"
metalness={0.5}
roughness={0.3}
emissive="#a78bfa"
emissiveIntensity={0.3}
/>
</mesh>
{/* Antenna */}
<mesh position={[0, 0.9, 0]}>
<cylinderGeometry args={[0.03, 0.03, 0.8, 8]} />
<meshStandardMaterial color="#cccccc" metalness={0.6} />
</mesh>
{/* Ring */}
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.7, 0.02, 8, 32]} />
<meshStandardMaterial
color="#00d4ff"
emissive="#00d4ff"
emissiveIntensity={0.5}
/>
</mesh>
{/* HTML overlay */}
<Html position={[2, 0, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#a78bfa' }}></div>
<div>: {getValue('temperature').toFixed(1)} </div>
<div>湿: {getValue('humidity').toFixed(1)} %</div>
</div>
</Html>
</group>
);
}
// ─── Fallback ───────────────────────────────────────────────────────
function DefaultDetail({ name }: { name: string }) {
const sphereRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (sphereRef.current) {
const mat = sphereRef.current.material as THREE.MeshStandardMaterial;
mat.emissiveIntensity = 0.3 + 0.2 * Math.sin(state.clock.elapsedTime * 2);
}
});
return (
<group>
<mesh ref={sphereRef}>
<sphereGeometry args={[0.6, 32, 32]} />
<meshStandardMaterial
color="#00d4ff"
emissive="#00d4ff"
emissiveIntensity={0.3}
metalness={0.4}
roughness={0.4}
/>
</mesh>
<Html position={[0, 1.2, 0]} distanceFactor={10}>
<div style={overlayStyle}>
<div style={{ textAlign: 'center', color: '#00d4ff' }}>{name}</div>
</div>
</Html>
</group>
);
}
// ─── Main Component ─────────────────────────────────────────────────
export default function DeviceDetailView({
device,
position,
realtimeData,
}: DeviceDetailViewProps) {
const groupRef = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (groupRef.current) {
groupRef.current.rotation.y += 0.05 * delta;
}
});
const getValue = (key: string) => realtimeData?.[key]?.value ?? 0;
const renderDetail = () => {
switch (device.device_type) {
case 'pv_inverter':
return <PVDetail getValue={getValue} />;
case 'heat_pump':
return <HeatPumpDetail getValue={getValue} />;
case 'meter':
return <MeterDetail getValue={getValue} />;
case 'heat_meter':
return <HeatMeterDetail getValue={getValue} />;
case 'sensor':
return <SensorDetail getValue={getValue} />;
default:
return <DefaultDetail name={device.name} />;
}
};
return (
<group ref={groupRef} position={position}>
{renderDetail()}
</group>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react';
import styles from '../styles.module.css';
import { getDeviceRealtime } from '../../../services/api';
interface Device {
id: number;
name: string;
code: string;
device_type: string;
status: string;
model?: string;
manufacturer?: string;
rated_power?: number;
}
interface DeviceInfoPanelProps {
device: Device | null;
onClose: () => void;
onViewDetail: (device: Device) => void;
}
interface ParamDef {
key: string;
label: string;
unit: string;
}
const PARAMS_BY_TYPE: Record<string, ParamDef[]> = {
pv_inverter: [
{ key: 'power', label: '功率', unit: 'kW' },
{ key: 'daily_energy', label: '日发电量', unit: 'kWh' },
{ key: 'total_energy', label: '累计发电', unit: 'kWh' },
{ key: 'dc_voltage', label: '直流电压', unit: 'V' },
{ key: 'ac_voltage', label: '交流电压', unit: 'V' },
{ key: 'temperature', label: '温度', unit: '℃' },
],
heat_pump: [
{ key: 'power', label: '功率', unit: 'kW' },
{ key: 'cop', label: 'COP', unit: '' },
{ key: 'inlet_temp', label: '进水温度', unit: '℃' },
{ key: 'outlet_temp', label: '出水温度', unit: '℃' },
{ key: 'flow_rate', label: '流量', unit: 'm³/h' },
{ key: 'outdoor_temp', label: '室外温度', unit: '℃' },
],
meter: [
{ key: 'power', label: '功率', unit: 'kW' },
{ key: 'voltage', label: '电压', unit: 'V' },
{ key: 'current', label: '电流', unit: 'A' },
{ key: 'power_factor', label: '功率因数', unit: '' },
],
sensor: [
{ key: 'temperature', label: '温度', unit: '℃' },
{ key: 'humidity', label: '湿度', unit: '%' },
],
heat_meter: [
{ key: 'heat_power', label: '热功率', unit: 'kW' },
{ key: 'flow_rate', label: '流量', unit: 'm³/h' },
{ key: 'supply_temp', label: '供水温度', unit: '℃' },
{ key: 'return_temp', label: '回水温度', unit: '℃' },
{ key: 'cumulative_heat', label: '累计热量', unit: 'GJ' },
],
};
const STATUS_COLORS: Record<string, string> = {
online: '#00ff88',
offline: '#666666',
alarm: '#ff4757',
maintenance: '#ff8c00',
};
const STATUS_LABELS: Record<string, string> = {
online: '在线',
offline: '离线',
alarm: '告警',
maintenance: '维护',
};
export default function DeviceInfoPanel({ device, onClose, onViewDetail }: DeviceInfoPanelProps) {
const [realtimeData, setRealtimeData] = useState<Record<string, { value: number; unit: string; timestamp: string }>>({});
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!device) {
setRealtimeData({});
return;
}
const fetchData = async () => {
try {
const resp = await getDeviceRealtime(device.id) as any;
// API returns { device: {...}, data: { power: {...}, ... } }
const realtimeMap = resp?.data ?? resp;
setRealtimeData(realtimeMap as Record<string, { value: number; unit: string; timestamp: string }>);
} catch {
// ignore fetch errors
}
};
fetchData();
timerRef.current = setInterval(fetchData, 5000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [device?.id]);
if (!device) return null;
const params = PARAMS_BY_TYPE[device.device_type] || [];
return (
<div className={styles.deviceInfoPanel}>
<div className={styles.infoPanelHeader}>
<span className={styles.infoPanelTitle}>{device.name}</span>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
<div>
<div className={styles.paramRow}>
<span className={styles.paramLabel}></span>
<span
style={{
padding: '2px 10px',
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
color: '#fff',
backgroundColor: STATUS_COLORS[device.status] || '#666',
}}
>
{STATUS_LABELS[device.status] || device.status}
</span>
</div>
{device.model && (
<div className={styles.paramRow}>
<span className={styles.paramLabel}></span>
<span className={styles.paramValue}>{device.model}</span>
</div>
)}
{device.manufacturer && (
<div className={styles.paramRow}>
<span className={styles.paramLabel}></span>
<span className={styles.paramValue}>{device.manufacturer}</span>
</div>
)}
{device.rated_power != null && (
<div className={styles.paramRow}>
<span className={styles.paramLabel}></span>
<span className={styles.paramValue}>{device.rated_power} kW</span>
</div>
)}
</div>
<div style={{ marginTop: 12 }}>
{params.map(param => {
const data = realtimeData[param.key];
const valueStr = data != null ? `${data.value}${param.unit ? ' ' + param.unit : ''}` : '--';
return (
<div key={param.key} className={styles.paramRow}>
<span className={styles.paramLabel}>{param.label}</span>
<span className={styles.paramValue}>{valueStr}</span>
</div>
);
})}
</div>
<button className={styles.detailBtn} onClick={() => onViewDetail(device)}>
</button>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
import styles from '../styles.module.css';
interface Device {
id: number;
name: string;
code: string;
device_type: string;
status: string;
primaryValue?: string;
}
interface DeviceListPanelProps {
devices: Device[];
selectedDeviceId: number | null;
onDeviceSelect: (device: Device) => void;
}
const TYPE_LABELS: Record<string, string> = {
pv_inverter: '光伏逆变器',
heat_pump: '空气源热泵',
meter: '电表',
sensor: '温湿度传感器',
heat_meter: '热量表',
};
const STATUS_COLORS: Record<string, string> = {
online: '#00ff88',
offline: '#666666',
alarm: '#ff4757',
maintenance: '#ff8c00',
};
export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSelect }: DeviceListPanelProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const groups: Record<string, Device[]> = {};
for (const device of devices) {
const type = device.device_type;
if (!groups[type]) groups[type] = [];
groups[type].push(device);
}
const toggleGroup = (type: string) => {
setCollapsed(prev => ({ ...prev, [type]: !prev[type] }));
};
return (
<div className={styles.deviceListPanel}>
{Object.entries(TYPE_LABELS).map(([type, label]) => {
const group = groups[type];
if (!group || group.length === 0) return null;
const isCollapsed = collapsed[type] ?? false;
return (
<div key={type}>
<div className={styles.deviceGroupTitle} onClick={() => toggleGroup(type)}>
{isCollapsed ? '▸' : '▾'} {label}
</div>
{!isCollapsed &&
group.map(device => (
<div
key={device.id}
className={`${styles.deviceItem} ${selectedDeviceId === device.id ? styles.deviceItemActive : ''}`}
onClick={() => onDeviceSelect(device)}
>
<span
className={styles.statusDot}
style={{ backgroundColor: STATUS_COLORS[device.status] || '#666666' }}
/>
<span className={styles.deviceName}>{device.name}</span>
{device.primaryValue && (
<span className={styles.deviceValue}>{device.primaryValue}</span>
)}
</div>
))}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useMemo } from 'react';
import { Html } from '@react-three/drei';
import { DEVICE_POSITIONS, COLORS } from '../constants';
interface DeviceMarkersProps {
devices: Array<{
id: number;
code: string;
device_type: string;
name: string;
status: string;
primaryValue?: string;
}>;
hoveredId: number | null;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; device_type: string; name: string; status: string; primaryValue?: string }) => void;
detailMode?: boolean;
}
const labelStyle: React.CSSProperties = {
fontSize: '11px',
color: COLORS.text,
background: 'rgba(6, 30, 62, 0.85)',
padding: '2px 6px',
borderRadius: '3px',
border: '1px solid rgba(0, 212, 255, 0.2)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
textAlign: 'center',
};
function MeterMarker({
device,
position,
isHovered,
accentColor,
onHover,
onClick,
}: {
device: DeviceMarkersProps['devices'][number];
position: [number, number, number];
isHovered: boolean;
accentColor: string;
onHover: (id: number | null) => void;
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
}) {
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
return (
<group
position={[position[0], position[1] + 0.6, position[2]]}
scale={scale}
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); onClick(device); }}
>
{/* Body */}
<mesh castShadow>
<boxGeometry args={[0.8, 1.2, 0.3]} />
<meshStandardMaterial color={accentColor} metalness={0.3} roughness={0.6} />
</mesh>
{/* Front dial */}
<mesh position={[0, 0.1, 0.16]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.25, 0.25, 0.05, 16]} />
<meshStandardMaterial color="#1a1a1a" metalness={0.5} />
</mesh>
{/* Label */}
<Html position={[0, 1, 0]} center>
<div style={labelStyle}>
<div>{device.name}</div>
{device.primaryValue && <div style={{ color: accentColor }}>{device.primaryValue}</div>}
</div>
</Html>
</group>
);
}
function SensorMarker({
device,
position,
isHovered,
onHover,
onClick,
}: {
device: DeviceMarkersProps['devices'][number];
position: [number, number, number];
isHovered: boolean;
onHover: (id: number | null) => void;
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
}) {
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
return (
<group
position={position}
scale={scale}
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); onClick(device); }}
>
{/* Sphere */}
<mesh castShadow>
<sphereGeometry args={[0.25, 16, 16]} />
<meshStandardMaterial
color={COLORS.sensorPurple}
metalness={0.5}
roughness={0.4}
emissive={COLORS.sensorPurple}
emissiveIntensity={isHovered ? 0.3 : 0.1}
/>
</mesh>
{/* Antenna */}
<mesh position={[0, 0.45, 0]}>
<cylinderGeometry args={[0.02, 0.02, 0.4, 6]} />
<meshStandardMaterial color="#b0b0b0" metalness={0.8} />
</mesh>
{/* Label */}
<Html position={[0, 0.9, 0]} center>
<div style={labelStyle}>
<div>{device.name}</div>
{device.primaryValue && <div style={{ color: COLORS.sensorPurple }}>{device.primaryValue}</div>}
</div>
</Html>
</group>
);
}
export default function DeviceMarkers({ devices, hoveredId, onHover, onClick }: DeviceMarkersProps) {
const categorized = useMemo(() => {
const meters: typeof devices = [];
const sensors: typeof devices = [];
const heatMeters: typeof devices = [];
devices.forEach((d) => {
if (d.device_type === 'heat_meter' || d.code.startsWith('HM-')) {
heatMeters.push(d);
} else if (d.code.startsWith('MTR-')) {
meters.push(d);
} else if (d.code.startsWith('SENSOR-')) {
sensors.push(d);
}
});
return { meters, sensors, heatMeters };
}, [devices]);
return (
<group>
{/* Regular meters */}
{categorized.meters.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<MeterMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
accentColor={COLORS.gridOrange}
onHover={onHover}
onClick={onClick}
/>
);
})}
{/* Heat meters */}
{categorized.heatMeters.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<MeterMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
accentColor={COLORS.alarmRed}
onHover={onHover}
onClick={onClick}
/>
);
})}
{/* Sensors */}
{categorized.sensors.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<SensorMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
onHover={onHover}
onClick={onClick}
/>
);
})}
</group>
);
}

View File

@@ -0,0 +1,164 @@
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { ENERGY_FLOW_PATHS } from '../constants';
interface EnergyParticlesProps {
energyFlow: { nodes: any[]; links: any[] };
realtimeData: { pv_power?: number; grid_power?: number; heatpump_power?: number } | null;
}
interface ParticlePathConfig {
key: string;
curve: THREE.CatmullRomCurve3;
color: string;
power: number;
}
const MAX_PARTICLES_PER_PATH = 40;
const MIN_PARTICLES = 3;
const PARTICLE_SIZE = 0.4;
const BASE_SPEED = 0.003;
const SPEED_VARIANCE = 0.002;
export default function EnergyParticles({ energyFlow, realtimeData }: EnergyParticlesProps) {
const pvPower = realtimeData?.pv_power ?? 0;
const gridPower = realtimeData?.grid_power ?? 0;
const hpPower = realtimeData?.heatpump_power ?? 0;
const paths = useMemo<ParticlePathConfig[]>(() => {
const configs: ParticlePathConfig[] = [];
// PV -> Building
if (pvPower > 0) {
configs.push({
key: 'pv',
curve: new THREE.CatmullRomCurve3(
ENERGY_FLOW_PATHS.pvToBuilding.waypoints.map((p) => new THREE.Vector3(...p)),
),
color: ENERGY_FLOW_PATHS.pvToBuilding.color,
power: pvPower,
});
}
// Grid -> Building (only when importing, i.e. positive)
if (gridPower > 0) {
configs.push({
key: 'grid',
curve: new THREE.CatmullRomCurve3(
ENERGY_FLOW_PATHS.gridToBuilding.waypoints.map((p) => new THREE.Vector3(...p)),
),
color: ENERGY_FLOW_PATHS.gridToBuilding.color,
power: gridPower,
});
}
// Building -> HeatPump
if (hpPower > 0) {
configs.push({
key: 'hp',
curve: new THREE.CatmullRomCurve3(
ENERGY_FLOW_PATHS.buildingToHeatPump.waypoints.map((p) => new THREE.Vector3(...p)),
),
color: ENERGY_FLOW_PATHS.buildingToHeatPump.color,
power: hpPower,
});
}
return configs;
}, [pvPower, gridPower, hpPower]);
return (
<group>
{paths.map((path) => (
<ParticlePath key={path.key} config={path} />
))}
</group>
);
}
function ParticlePath({ config }: { config: ParticlePathConfig }) {
const { curve, color, power } = config;
const count = Math.max(
MIN_PARTICLES,
Math.min(Math.floor(power / 5), MAX_PARTICLES_PER_PATH),
);
// Stable random speeds per particle
const speeds = useMemo(
() => Array.from({ length: count }, () => BASE_SPEED + Math.random() * SPEED_VARIANCE),
[count],
);
// Progress values (0..1) for each particle along the curve
const progressRef = useRef<Float32Array>(new Float32Array(0));
if (progressRef.current.length !== count) {
progressRef.current = new Float32Array(count);
for (let i = 0; i < count; i++) {
progressRef.current[i] = Math.random();
}
}
const positionsRef = useRef<Float32Array>(new Float32Array(count * 3));
if (positionsRef.current.length !== count * 3) {
positionsRef.current = new Float32Array(count * 3);
}
const geomRef = useRef<THREE.BufferGeometry>(null);
// Initialize positions
useMemo(() => {
const pos = positionsRef.current;
for (let i = 0; i < count; i++) {
const pt = curve.getPointAt(progressRef.current[i]);
pos[i * 3] = pt.x;
pos[i * 3 + 1] = pt.y;
pos[i * 3 + 2] = pt.z;
}
}, [curve, count]);
useFrame(() => {
const prog = progressRef.current;
const pos = positionsRef.current;
for (let i = 0; i < count; i++) {
prog[i] = (prog[i] + speeds[i]) % 1;
const pt = curve.getPointAt(prog[i]);
pos[i * 3] = pt.x;
pos[i * 3 + 1] = pt.y;
pos[i * 3 + 2] = pt.z;
}
if (geomRef.current) {
const attr = geomRef.current.getAttribute('position') as THREE.BufferAttribute;
attr.needsUpdate = true;
}
});
const material = useMemo(
() =>
new THREE.PointsMaterial({
size: PARTICLE_SIZE,
color: new THREE.Color(color),
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
transparent: true,
opacity: 0.8,
depthWrite: false,
}),
[color],
);
return (
<points material={material}>
<bufferGeometry ref={geomRef}>
<bufferAttribute
attach="attributes-position"
args={[positionsRef.current, 3]}
count={count}
/>
</bufferGeometry>
</points>
);
}

View File

@@ -0,0 +1,22 @@
import { Grid } from '@react-three/drei';
export default function Ground() {
return (
<group>
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position={[0, -0.01, 0]}>
<planeGeometry args={[100, 100]} />
<meshStandardMaterial color="#0d2137" />
</mesh>
<Grid
args={[100, 100]}
cellSize={2}
cellColor="#1a3a5c"
sectionSize={10}
sectionColor="#2a5a8c"
fadeDistance={80}
position={[0, 0, 0]}
infiniteGrid={false}
/>
</group>
);
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import styles from '../styles.module.css';
import AnimatedNumber from '../../BigScreen/components/AnimatedNumber';
interface HUDOverlayProps {
overview: {
today_generation?: number;
today_consumption?: number;
carbon_reduction?: number;
active_alarms?: number;
} | null;
realtimeData: {
pv_power?: number;
grid_power?: number;
heat_pump_power?: number;
total_load?: number;
} | null;
deviceStats: {
online?: number;
total?: number;
} | null;
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const w = WEEKDAYS[date.getDay()];
return `${y}${m}${d}日 星期${w}`;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('zh-CN', { hour12: false });
}
export default function HUDOverlay({ overview, realtimeData }: HUDOverlayProps) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
return (
<div className={styles.hudOverlay}>
<div className={styles.header}>
<span className={styles.headerDate}>{formatDate(now)}</span>
<span className={styles.headerTitle}> 3D智慧能源管理平台</span>
<span className={styles.headerClock}>{formatTime(now)}</span>
</div>
<div className={styles.metricsBar}>
<div className={styles.metricCard}>
<span className={styles.metricLabel}></span>
<span className={styles.metricValue}>
<AnimatedNumber value={realtimeData?.pv_power ?? 0} decimals={1} />
<span className={styles.metricUnit}>kW</span>
</span>
</div>
<div className={styles.metricCard}>
<span className={styles.metricLabel}></span>
<span className={styles.metricValue}>
<AnimatedNumber value={realtimeData?.grid_power ?? 0} decimals={1} />
<span className={styles.metricUnit}>kW</span>
</span>
</div>
<div className={styles.metricCard}>
<span className={styles.metricLabel}></span>
<span className={styles.metricValue}>
<AnimatedNumber value={realtimeData?.total_load ?? 0} decimals={1} />
<span className={styles.metricUnit}>kW</span>
</span>
</div>
<div className={styles.metricCard}>
<span className={styles.metricLabel}></span>
<span className={styles.metricValue}>
<AnimatedNumber value={overview?.today_generation ?? 0} decimals={1} />
<span className={styles.metricUnit}>kWh</span>
</span>
</div>
<div className={styles.metricCard}>
<span className={styles.metricLabel}></span>
<span className={styles.metricValue}>
<AnimatedNumber value={overview?.carbon_reduction ?? 0} decimals={1} />
<span className={styles.metricUnit}>kg</span>
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useRef, useMemo } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { DEVICE_POSITIONS } from '../constants';
interface HeatPumpsProps {
devices: Array<{ id: number; code: string; status: string; power?: number }>;
hoveredId: number | null;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number }) => void;
detailMode?: boolean;
}
const HP_CODES = ['HP-01', 'HP-02', 'HP-03', 'HP-04'] as const;
function FanBlades({ speed, status }: { speed: number; status: string }) {
const bladesRef = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (!bladesRef.current) return;
if (status === 'offline') return;
bladesRef.current.rotation.y += speed * delta;
});
return (
<group position={[0, 0.8, 0]}>
{/* Fan housing */}
<mesh>
<cylinderGeometry args={[0.6, 0.6, 0.1, 24]} />
<meshStandardMaterial color="#3a5a7a" metalness={0.7} roughness={0.3} />
</mesh>
{/* Blades */}
<group ref={bladesRef} position={[0, 0.06, 0]}>
{[0, 1, 2].map((i) => (
<mesh key={i} rotation={[0, (i * Math.PI * 2) / 3, 0]}>
<boxGeometry args={[0.5, 0.02, 0.1]} />
<meshStandardMaterial color="#b0c4de" metalness={0.8} roughness={0.2} />
</mesh>
))}
</group>
</group>
);
}
function HeatPumpUnit({
position,
device,
isHovered,
onHover,
onClick,
}: {
position: [number, number, number];
device: { id: number; code: string; status: string; power?: number } | undefined;
isHovered: boolean;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number }) => void;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const status = device?.status ?? 'offline';
const power = device?.power ?? 0;
const fanSpeed = status !== 'offline' ? (power / 35) * 2 : 0;
useFrame((state) => {
if (!meshRef.current) return;
const mat = meshRef.current.material as THREE.MeshStandardMaterial;
if (status === 'alarm') {
mat.emissiveIntensity = Math.sin(state.clock.elapsedTime * 3) * 0.3 + 0.2;
}
});
const bodyColor = status === 'offline' ? '#444444' : '#2a4a6a';
const emissiveColor = status === 'alarm' ? '#ff4757' : status === 'online' ? '#00d4ff' : '#000000';
const emissiveIntensity = status === 'offline' ? 0 : isHovered ? 0.3 : 0.1;
const scale: [number, number, number] = isHovered ? [1.05, 1.05, 1.05] : [1, 1, 1];
return (
<group
position={position}
scale={scale}
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
>
{/* Main body */}
<mesh ref={meshRef} castShadow>
<boxGeometry args={[2, 1.5, 1.5]} />
<meshStandardMaterial
color={bodyColor}
metalness={0.6}
roughness={0.4}
emissive={emissiveColor}
emissiveIntensity={emissiveIntensity}
/>
</mesh>
{/* Fan on top */}
<FanBlades speed={fanSpeed} status={status} />
{/* Side pipes - left */}
<mesh position={[-1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
<meshStandardMaterial color="#556677" metalness={0.8} />
</mesh>
<mesh position={[-1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
<meshStandardMaterial color="#556677" metalness={0.8} />
</mesh>
{/* Side pipes - right */}
<mesh position={[1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
<meshStandardMaterial color="#556677" metalness={0.8} />
</mesh>
<mesh position={[1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
<meshStandardMaterial color="#556677" metalness={0.8} />
</mesh>
</group>
);
}
export default function HeatPumps({ devices, hoveredId, onHover, onClick }: HeatPumpsProps) {
const deviceMap = useMemo(() => {
const map = new Map<string, (typeof devices)[number]>();
devices.forEach((d) => map.set(d.code, d));
return map;
}, [devices]);
return (
<group>
{HP_CODES.map((code) => {
const pos = DEVICE_POSITIONS[code].position;
const device = deviceMap.get(code);
return (
<HeatPumpUnit
key={code}
position={[pos[0], pos[1] + 0.75, pos[2]]}
device={device}
isHovered={device ? hoveredId === device.id : false}
onHover={onHover}
onClick={onClick}
/>
);
})}
</group>
);
}

View File

@@ -0,0 +1,107 @@
import { useRef, useMemo } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { DEVICE_POSITIONS, PV_ARRAY, COLORS } from '../constants';
interface PVPanelsProps {
devices: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }>;
hoveredId: number | null;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
detailMode?: boolean;
}
const PV_ZONES = [
{ code: 'PV-INV-01', center: DEVICE_POSITIONS['PV-INV-01'].position },
{ code: 'PV-INV-02', center: DEVICE_POSITIONS['PV-INV-02'].position },
{ code: 'PV-INV-03', center: DEVICE_POSITIONS['PV-INV-03'].position },
] as const;
function PVZone({
center,
device,
isHovered,
onHover,
onClick,
}: {
center: readonly [number, number, number];
device: { id: number; code: string; status: string; power?: number; rated_power?: number } | undefined;
isHovered: boolean;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
}) {
const groupRef = useRef<THREE.Group>(null);
const panels = useMemo(() => {
const items: { pos: [number, number, number] }[] = [];
const { cols, rows, panelWidth, panelHeight, gap } = PV_ARRAY;
const totalW = cols * panelWidth + (cols - 1) * gap;
const totalD = rows * panelHeight + (rows - 1) * gap;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = -totalW / 2 + panelWidth / 2 + c * (panelWidth + gap);
const z = -totalD / 2 + panelHeight / 2 + r * (panelHeight + gap);
items.push({ pos: [x, 0, z] });
}
}
return items;
}, []);
const ratio = device && device.rated_power ? (device.power ?? 0) / device.rated_power : 0;
const emissiveIntensity = Math.min(ratio * 0.5, 0.5);
return (
<group
ref={groupRef}
position={[center[0], center[1], center[2]]}
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
>
{panels.map((p, i) => (
<mesh
key={i}
position={p.pos}
rotation={[-PV_ARRAY.tiltAngle, 0, 0]}
castShadow
>
<boxGeometry args={[PV_ARRAY.panelWidth, PV_ARRAY.panelDepth, PV_ARRAY.panelHeight]} />
<meshStandardMaterial
color={isHovered ? '#2a347e' : '#1a237e'}
metalness={0.8}
roughness={0.3}
emissive={COLORS.pvGreen}
emissiveIntensity={isHovered ? 0.5 : emissiveIntensity}
/>
</mesh>
))}
</group>
);
}
export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) {
const deviceMap = useMemo(() => {
const map = new Map<string, (typeof devices)[number]>();
devices.forEach((d) => map.set(d.code, d));
return map;
}, [devices]);
return (
<group>
{PV_ZONES.map((zone) => {
const device = deviceMap.get(zone.code);
return (
<PVZone
key={zone.code}
center={zone.center}
device={device}
isHovered={device ? hoveredId === device.id : false}
onHover={onHover}
onClick={onClick}
/>
);
})}
</group>
);
}

View File

@@ -0,0 +1,24 @@
import { Stars } from '@react-three/drei';
export default function SceneEnvironment() {
return (
<>
<ambientLight intensity={0.15} color="#4488cc" />
<directionalLight
position={[10, 20, 10]}
intensity={0.4}
color="#88bbff"
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-camera-far={80}
shadow-camera-left={-40}
shadow-camera-right={40}
shadow-camera-top={40}
shadow-camera-bottom={-40}
/>
<Stars count={2000} factor={4} saturation={0} fade speed={1} />
<fog attach="fog" args={['#0a1628', 50, 150]} />
</>
);
}

View File

@@ -0,0 +1,120 @@
// Campus layout constants for 3D scene
// Scale: 1 unit ≈ 2 meters
// ============ Colors (matching existing BigScreen dark theme) ============
export const COLORS = {
background: '#0a1628',
primary: '#00d4ff',
pvGreen: '#00ff88',
gridOrange: '#ff8c00',
alarmRed: '#ff4757',
sensorPurple: '#a78bfa',
heatPumpCyan: '#00d4ff',
buildingBase: '#1a3a5c',
buildingEdge: 'rgba(0, 212, 255, 0.3)',
windowGlow: '#ffcc66',
cardBg: 'rgba(6, 30, 62, 0.85)',
cardBorder: 'rgba(0, 212, 255, 0.25)',
text: '#e0e8f0',
textSecondary: '#8899aa',
groundGrid: '#0d2137',
groundLine: '#1a3a5c',
} as const;
// ============ Buildings ============
export const BUILDINGS = {
east: {
label: '东楼',
position: [12, 6, -5] as [number, number, number],
size: [20, 12, 15] as [number, number, number],
},
west: {
label: '西楼',
position: [-12, 5, -5] as [number, number, number],
size: [16, 10, 12] as [number, number, number],
},
} as const;
// ============ Device Positions ============
export const DEVICE_POSITIONS: Record<string, {
position: [number, number, number];
rotation?: [number, number, number];
type: string;
}> = {
// PV Inverters (on rooftops)
'PV-INV-01': { position: [6, 12.3, -8], type: 'pv_inverter' },
'PV-INV-02': { position: [18, 12.3, -8], type: 'pv_inverter' },
'PV-INV-03': { position: [-12, 10.3, -8], type: 'pv_inverter' },
// Heat Pumps (ground level, beside buildings)
'HP-01': { position: [24, 0, 2], type: 'heat_pump' },
'HP-02': { position: [24, 0, 6], type: 'heat_pump' },
'HP-03': { position: [-24, 0, 2], type: 'heat_pump' },
'HP-04': { position: [-24, 0, 6], type: 'heat_pump' },
// Meters (ground, near entrances)
'MTR-GRID': { position: [0, 0, 14], type: 'meter' },
'MTR-PV': { position: [3, 0, 14], type: 'meter' },
'MTR-HP': { position: [26, 0, -1], type: 'meter' },
'MTR-PUMP': { position: [-26, 0, -1], type: 'meter' },
// Sensors (elevated, on buildings)
'SENSOR-01': { position: [8, 6, 2], type: 'sensor' },
'SENSOR-02': { position: [16, 6, 2], type: 'sensor' },
'SENSOR-03': { position: [-8, 5, 2], type: 'sensor' },
'SENSOR-04': { position: [-16, 5, 2], type: 'sensor' },
'SENSOR-05': { position: [0, 4, 5], type: 'sensor' },
// Heat Meter
'HM-01': { position: [26, 0, 4], type: 'heat_meter' },
};
// ============ Camera ============
export const CAMERA = {
campusPosition: [0, 35, 45] as [number, number, number],
campusTarget: [0, 0, 0] as [number, number, number],
fov: 45,
near: 0.1,
far: 500,
detailDistance: 8,
animationDuration: 1.5,
} as const;
// ============ Energy Flow Paths ============
export const ENERGY_FLOW_PATHS = {
pvToBuilding: {
color: '#00ff88',
waypoints: [[12, 13, -8], [12, 10, 0], [0, 6, 5]] as [number, number, number][],
},
gridToBuilding: {
color: '#ff8c00',
waypoints: [[0, 1, 14], [0, 4, 10], [0, 6, 5]] as [number, number, number][],
},
buildingToHeatPump: {
color: '#00d4ff',
waypoints: [[0, 6, 5], [12, 3, 4], [24, 1, 4]] as [number, number, number][],
},
} as const;
// ============ PV Panel Array ============
export const PV_ARRAY = {
cols: 5,
rows: 3,
panelWidth: 2,
panelHeight: 1,
panelDepth: 0.05,
gap: 0.3,
tiltAngle: Math.PI / 6, // 30 degrees
} as const;
// ============ Polling ============
export const POLL_INTERVAL = 15000; // 15 seconds
export const DETAIL_POLL_INTERVAL = 5000; // 5 seconds for selected device
// ============ Status Colors ============
export const STATUS_COLORS: Record<string, string> = {
online: '#00ff88',
offline: '#666666',
alarm: '#ff4757',
maintenance: '#ff8c00',
};

View File

@@ -0,0 +1,69 @@
import { useRef, useCallback } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { CAMERA } from '../constants';
export function useCameraAnimation() {
const { camera } = useThree();
const isAnimating = useRef(false);
const startPos = useRef(new THREE.Vector3());
const endPos = useRef(new THREE.Vector3());
const startTarget = useRef(new THREE.Vector3());
const endTarget = useRef(new THREE.Vector3());
const progress = useRef(0);
const duration = useRef(CAMERA.animationDuration);
const currentTarget = useRef(new THREE.Vector3());
const animateTo = useCallback(
(position: [number, number, number], target: [number, number, number], dur?: number) => {
startPos.current.copy(camera.position);
endPos.current.set(...position);
// Estimate current look-at target from camera direction
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
startTarget.current.copy(camera.position).add(dir.multiplyScalar(10));
endTarget.current.set(...target);
duration.current = dur ?? CAMERA.animationDuration;
progress.current = 0;
isAnimating.current = true;
},
[camera],
);
const resetToOverview = useCallback(() => {
animateTo(
CAMERA.campusPosition as [number, number, number],
CAMERA.campusTarget as [number, number, number],
);
}, [animateTo]);
useFrame((_, delta) => {
if (!isAnimating.current) return;
progress.current = Math.min(progress.current + delta / duration.current, 1);
// Smooth ease-in-out
const t = progress.current < 0.5
? 2 * progress.current * progress.current
: 1 - Math.pow(-2 * progress.current + 2, 2) / 2;
camera.position.lerpVectors(startPos.current, endPos.current, t);
currentTarget.current.lerpVectors(startTarget.current, endTarget.current, t);
camera.lookAt(currentTarget.current);
if (progress.current >= 1) {
isAnimating.current = false;
}
});
return {
animateTo,
resetToOverview,
get isAnimating() {
return isAnimating.current;
},
};
}

View File

@@ -0,0 +1,129 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getDevices, getDeviceStats, getDashboardOverview, getRealtimeData } from '../../../services/api';
import type { DeviceInfo, DeviceWithPosition, OverviewData, RealtimePowerData } from '../types';
import { DEVICE_POSITIONS, POLL_INTERVAL } from '../constants';
interface DeviceStats {
online: number;
offline: number;
alarm: number;
maintenance: number;
total: number;
}
// Ordered position keys by device type for fuzzy matching
const POSITION_KEYS_BY_TYPE: Record<string, string[]> = {
pv_inverter: ['PV-INV-01', 'PV-INV-02', 'PV-INV-03'],
heat_pump: ['HP-01', 'HP-02', 'HP-03', 'HP-04'],
meter: ['MTR-GRID', 'MTR-PV', 'MTR-HP', 'MTR-PUMP'],
sensor: ['SENSOR-01', 'SENSOR-02', 'SENSOR-03', 'SENSOR-04', 'SENSOR-05'],
heat_meter: ['HM-01'],
};
function matchDevicesToPositions(devices: DeviceInfo[]): DeviceWithPosition[] {
const usedPositions = new Set<string>();
const result: DeviceWithPosition[] = [];
// Group devices by type
const byType: Record<string, DeviceInfo[]> = {};
for (const device of devices) {
const type = device.device_type || 'unknown';
if (!byType[type]) byType[type] = [];
byType[type].push(device);
}
for (const [type, typeDevices] of Object.entries(byType)) {
const positionKeys = POSITION_KEYS_BY_TYPE[type] || [];
typeDevices.forEach((device, index) => {
// Try exact match by device code first
let matchedKey: string | undefined;
if (device.code && DEVICE_POSITIONS[device.code] && !usedPositions.has(device.code)) {
matchedKey = device.code;
}
// Fall back to ordered assignment by type
if (!matchedKey && index < positionKeys.length) {
const key = positionKeys[index];
if (!usedPositions.has(key)) {
matchedKey = key;
}
}
const withPos: DeviceWithPosition = { ...device };
if (matchedKey) {
usedPositions.add(matchedKey);
const posData = DEVICE_POSITIONS[matchedKey];
withPos.position3D = posData.position;
withPos.rotation3D = posData.rotation;
// Override code with matched key so 3D components can look up positions by code
withPos.code = matchedKey;
}
result.push(withPos);
});
}
return result;
}
export function useDeviceData() {
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [deviceStats, setDeviceStats] = useState<DeviceStats | null>(null);
const [overview, setOverview] = useState<OverviewData | null>(null);
const [realtimeData, setRealtimeData] = useState<RealtimePowerData | null>(null);
const [devicesWithPositions, setDevicesWithPositions] = useState<DeviceWithPosition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchAll = useCallback(async () => {
try {
const results = await Promise.allSettled([
getDevices({ page_size: 100 }) as Promise<any>,
getDeviceStats() as Promise<any>,
getDashboardOverview() as Promise<any>,
getRealtimeData() as Promise<any>,
]);
// Devices
if (results[0].status === 'fulfilled') {
const devData = results[0].value;
const items: DeviceInfo[] = devData?.items || [];
setDevices(items);
setDevicesWithPositions(matchDevicesToPositions(items));
}
// Stats
if (results[1].status === 'fulfilled') {
setDeviceStats(results[1].value as DeviceStats);
}
// Overview
if (results[2].status === 'fulfilled') {
setOverview(results[2].value as OverviewData);
}
// Realtime
if (results[3].status === 'fulfilled') {
setRealtimeData(results[3].value as RealtimePowerData);
}
setError(null);
} catch (err: any) {
setError(err?.message || 'Failed to fetch device data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
intervalRef.current = setInterval(fetchAll, POLL_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchAll]);
return { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading, error };
}

View File

@@ -0,0 +1,33 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getEnergyFlow } from '../../../services/api';
import type { EnergyFlowNode, EnergyFlowLink } from '../types';
import { POLL_INTERVAL } from '../constants';
export function useEnergyFlow() {
const [nodes, setNodes] = useState<EnergyFlowNode[]>([]);
const [links, setLinks] = useState<EnergyFlowLink[]>([]);
const [loading, setLoading] = useState(true);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchFlow = useCallback(async () => {
try {
const data = (await getEnergyFlow()) as any;
setNodes(data?.nodes || []);
setLinks(data?.links || []);
} catch {
// Silently ignore — stale data is acceptable for flow visualization
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFlow();
intervalRef.current = setInterval(fetchFlow, POLL_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchFlow]);
return { nodes, links, loading };
}

View File

@@ -0,0 +1,132 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import styles from './styles.module.css';
import type { DeviceInfo, ViewMode } from './types';
import { useDeviceData } from './hooks/useDeviceData';
import { useEnergyFlow } from './hooks/useEnergyFlow';
import { getDeviceRealtime } from '../../services/api';
import CampusScene from './components/CampusScene';
import HUDOverlay from './components/HUDOverlay';
import DeviceListPanel from './components/DeviceListPanel';
import DeviceInfoPanel from './components/DeviceInfoPanel';
export default function BigScreen3D() {
const { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading } = useDeviceData();
const { nodes, links } = useEnergyFlow();
const [selectedDevice, setSelectedDevice] = useState<DeviceInfo | null>(null);
const [hoveredDeviceId, setHoveredDeviceId] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('campus');
const [detailRealtimeData, setDetailRealtimeData] = useState<Record<string, { value: number; unit: string }> | null>(null);
const detailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Poll per-device realtime data when in device-detail view
useEffect(() => {
if (!selectedDevice || viewMode !== 'device-detail') {
setDetailRealtimeData(null);
if (detailTimerRef.current) clearInterval(detailTimerRef.current);
return;
}
const fetchDetail = async () => {
try {
const resp = await getDeviceRealtime(selectedDevice.id) as any;
const realtimeMap = resp?.data ?? resp;
setDetailRealtimeData(realtimeMap as Record<string, { value: number; unit: string }>);
} catch {
// ignore errors
}
};
fetchDetail();
detailTimerRef.current = setInterval(fetchDetail, 5000);
return () => {
if (detailTimerRef.current) clearInterval(detailTimerRef.current);
};
}, [selectedDevice?.id, viewMode]);
const handleDeviceSelect = useCallback((device: DeviceInfo) => {
setSelectedDevice(device);
}, []);
const handleDeviceClose = useCallback(() => {
setSelectedDevice(null);
}, []);
const handleEnterDetail = useCallback((device: DeviceInfo) => {
setSelectedDevice(device);
setViewMode('device-detail');
}, []);
const handleExitDetail = useCallback(() => {
setViewMode('campus');
}, []);
// Find 3D position of the selected device
const selectedDevicePosition = selectedDevice
? (devicesWithPositions.find((d) => d.id === selectedDevice.id)?.position3D ?? null)
: null;
if (loading && devicesWithPositions.length === 0) {
return (
<div className={styles.container}>
<div className={styles.placeholder}>
<h2 className={styles.placeholderTitle}> 3D智慧能源管理平台</h2>
<p style={{ color: '#8899aa' }}>...</p>
</div>
</div>
);
}
return (
<div className={styles.container}>
{/* 3D Canvas — fills entire screen */}
<div className={styles.canvasWrapper}>
<CampusScene
devices={devicesWithPositions}
energyFlow={{ nodes, links }}
realtimeData={realtimeData}
selectedDevice={selectedDevice}
selectedDevicePosition={selectedDevicePosition}
detailRealtimeData={detailRealtimeData}
hoveredDeviceId={hoveredDeviceId}
viewMode={viewMode}
onDeviceSelect={handleDeviceSelect}
onDeviceHover={setHoveredDeviceId}
/>
</div>
{/* HUD: header bar + bottom metrics — pointer-events: none */}
<HUDOverlay
overview={overview}
realtimeData={realtimeData}
deviceStats={deviceStats}
/>
{/* Left device list panel (only in campus view) */}
{viewMode === 'campus' && (
<DeviceListPanel
devices={devicesWithPositions}
selectedDeviceId={selectedDevice?.id ?? null}
onDeviceSelect={handleDeviceSelect}
/>
)}
{/* Right device info panel (when device selected in campus view) */}
{selectedDevice && viewMode === 'campus' && (
<DeviceInfoPanel
device={selectedDevice}
onClose={handleDeviceClose}
onViewDetail={handleEnterDetail}
/>
)}
{/* Return button in detail view */}
{viewMode === 'device-detail' && (
<button className={styles.returnBtn} onClick={handleExitDetail}>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,329 @@
/* BigScreen 3D - Dark monitoring theme */
.container {
width: 100vw;
height: 100vh;
background: #0a1628;
position: relative;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e0e8f0;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #00d4ff;
}
.placeholderTitle {
font-size: 2rem;
margin-bottom: 1rem;
}
/* Canvas fills entire screen */
.canvasWrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* HUD overlay on top of canvas */
.hudOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
}
/* Header bar */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: linear-gradient(180deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%);
pointer-events: none;
}
.headerDate {
font-size: 14px;
color: #8899aa;
min-width: 200px;
}
.headerTitle {
font-size: 24px;
font-weight: 700;
background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 3s linear infinite;
text-align: center;
}
@keyframes shimmer {
to { background-position: 200% center; }
}
.headerClock {
font-size: 20px;
font-weight: 600;
color: #00d4ff;
font-variant-numeric: tabular-nums;
min-width: 200px;
text-align: right;
}
/* Bottom metrics bar */
.metricsBar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: center;
gap: 24px;
padding: 16px 24px;
background: linear-gradient(0deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%);
pointer-events: none;
}
.metricCard {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 20px;
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
min-width: 140px;
}
.metricLabel {
font-size: 12px;
color: #8899aa;
margin-bottom: 4px;
}
.metricValue {
font-size: 22px;
font-weight: 700;
color: #00d4ff;
font-variant-numeric: tabular-nums;
}
.metricUnit {
font-size: 12px;
color: #8899aa;
margin-left: 4px;
}
/* Left device list panel */
.deviceListPanel {
position: absolute;
top: 60px;
left: 16px;
width: 240px;
max-height: calc(100vh - 160px);
overflow-y: auto;
background: rgba(6, 30, 62, 0.85);
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 8px;
padding: 12px;
z-index: 20;
pointer-events: auto;
}
.deviceListPanel::-webkit-scrollbar {
width: 4px;
}
.deviceListPanel::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.3);
border-radius: 2px;
}
.deviceGroupTitle {
font-size: 13px;
font-weight: 600;
color: #00d4ff;
padding: 8px 0 4px;
border-bottom: 1px solid rgba(0, 212, 255, 0.15);
margin-bottom: 4px;
cursor: pointer;
user-select: none;
}
.deviceItem {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
gap: 8px;
}
.deviceItem:hover {
background: rgba(0, 212, 255, 0.1);
}
.deviceItemActive {
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.3);
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.deviceName {
font-size: 12px;
color: #e0e8f0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deviceValue {
font-size: 11px;
color: #00d4ff;
font-variant-numeric: tabular-nums;
}
/* Right device info panel */
.deviceInfoPanel {
position: absolute;
top: 60px;
right: 16px;
width: 300px;
max-height: calc(100vh - 160px);
overflow-y: auto;
background: rgba(6, 30, 62, 0.9);
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 8px;
padding: 16px;
z-index: 20;
pointer-events: auto;
}
.infoPanelHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.infoPanelTitle {
font-size: 16px;
font-weight: 600;
color: #e0e8f0;
}
.closeBtn {
background: none;
border: 1px solid rgba(0, 212, 255, 0.3);
color: #8899aa;
font-size: 14px;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.closeBtn:hover {
color: #00d4ff;
border-color: #00d4ff;
}
.paramRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.paramLabel {
font-size: 13px;
color: #8899aa;
}
.paramValue {
font-size: 15px;
font-weight: 600;
color: #00d4ff;
font-variant-numeric: tabular-nums;
}
.detailBtn {
width: 100%;
margin-top: 12px;
padding: 8px;
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 6px;
color: #00d4ff;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.detailBtn:hover {
background: rgba(0, 212, 255, 0.25);
}
/* Return button (detail view) */
.returnBtn {
position: absolute;
top: 70px;
left: 50%;
transform: translateX(-50%);
padding: 8px 24px;
background: rgba(6, 30, 62, 0.9);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 20px;
color: #00d4ff;
font-size: 14px;
cursor: pointer;
z-index: 25;
pointer-events: auto;
transition: all 0.2s;
}
.returnBtn:hover {
background: rgba(0, 212, 255, 0.2);
}
/* 3D label styles (used inside drei Html) */
.label3d {
font-size: 11px;
color: #e0e8f0;
background: rgba(6, 30, 62, 0.8);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(0, 212, 255, 0.2);
white-space: nowrap;
pointer-events: none;
}
.label3dValue {
color: #00d4ff;
font-weight: 600;
margin-left: 4px;
}

View File

@@ -0,0 +1,73 @@
// 3D BigScreen shared type definitions
export interface DeviceInfo {
id: number;
name: string;
code: string;
device_type: string;
device_type_id?: number;
status: 'online' | 'offline' | 'alarm' | 'maintenance';
model?: string;
manufacturer?: string;
rated_power?: number;
location?: string;
serial_number?: string;
collect_interval?: number;
}
export interface DeviceRealtimeEntry {
value: number;
unit: string;
timestamp: string;
}
export type DeviceRealtimeData = Record<string, DeviceRealtimeEntry>;
export interface EnergyFlowNode {
id: string;
name: string;
power: number;
unit: string;
}
export interface EnergyFlowLink {
source: string;
target: string;
value: number;
}
export interface DevicePosition3D {
deviceCode: string;
position: [number, number, number];
rotation?: [number, number, number];
type: string;
}
export type ViewMode = 'campus' | 'device-detail';
export interface SceneState {
viewMode: ViewMode;
selectedDevice: DeviceInfo | null;
hoveredDeviceId: number | null;
}
export interface DeviceWithPosition extends DeviceInfo {
position3D?: [number, number, number];
rotation3D?: [number, number, number];
}
export interface OverviewData {
total_devices?: number;
online_devices?: number;
today_consumption?: number;
today_generation?: number;
carbon_reduction?: number;
active_alarms?: number;
}
export interface RealtimePowerData {
pv_power?: number;
heatpump_power?: number;
total_load?: number;
grid_power?: number;
}

View File

@@ -0,0 +1,304 @@
import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons';
import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api';
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
offline: { color: 'default', text: '离线' },
alarm: { color: 'red', text: '告警' },
maintenance: { color: 'orange', text: '维护' },
};
const protocolOptions = [
{ label: 'Modbus TCP', value: 'modbus_tcp' },
{ label: 'Modbus RTU', value: 'modbus_rtu' },
{ label: 'OPC UA', value: 'opc_ua' },
{ label: 'MQTT', value: 'mqtt' },
{ label: 'HTTP API', value: 'http_api' },
{ label: 'DL/T 645', value: 'dlt645' },
{ label: '图像采集', value: 'image' },
];
export default function Devices() {
const [data, setData] = useState<any>({ total: 0, items: [] });
const [stats, setStats] = useState<any>({ online: 0, offline: 0, alarm: 0, total: 0 });
const [deviceTypes, setDeviceTypes] = useState<any[]>([]);
const [deviceGroups, setDeviceGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<any>(null);
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
const loadDevices = useCallback(async (params?: Record<string, any>) => {
setLoading(true);
try {
const query = params || filters;
// Remove empty values
const cleanQuery: Record<string, any> = {};
Object.entries(query).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
});
const res = await getDevices(cleanQuery);
setData(res as any);
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [filters]);
const loadMeta = async () => {
try {
const [types, groups, st] = await Promise.all([
getDeviceTypes(), getDeviceGroups(), getDeviceStats(),
]);
setDeviceTypes(types as any[]);
setDeviceGroups(groups as any[]);
setStats(st as any);
} catch (e) { console.error(e); }
};
useEffect(() => { loadMeta(); }, []);
useEffect(() => { loadDevices(); }, [filters, loadDevices]);
const handleFilterChange = (key: string, value: any) => {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
};
const handlePageChange = (page: number, pageSize: number) => {
setFilters(prev => ({ ...prev, page, page_size: pageSize }));
};
const openAddModal = () => {
setEditingDevice(null);
form.resetFields();
form.setFieldsValue({ collect_interval: 15, is_active: true });
setShowModal(true);
};
const openEditModal = (record: any) => {
setEditingDevice(record);
form.setFieldsValue({
...record,
device_type_id: record.device_type_id,
device_group_id: record.device_group_id,
connection_params: record.connection_params ? JSON.stringify(record.connection_params, null, 2) : '',
});
setShowModal(true);
};
const handleSubmit = async (values: any) => {
try {
// Parse connection_params if provided as string
if (values.connection_params && typeof values.connection_params === 'string') {
try {
values.connection_params = JSON.parse(values.connection_params);
} catch {
message.error('连接参数JSON格式错误');
return;
}
}
if (editingDevice) {
await updateDevice(editingDevice.id, values);
message.success('设备更新成功');
} else {
await createDevice(values);
message.success('设备创建成功');
}
setShowModal(false);
form.resetFields();
loadDevices();
loadMeta();
} catch (e: any) {
message.error(e?.detail || '操作失败');
}
};
const columns = [
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '设备编号', dataIndex: 'code', width: 130 },
{ title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },
{ title: '设备分组', dataIndex: 'device_group_name', width: 120 },
{ title: '型号', dataIndex: 'model', width: 120, ellipsis: true },
{ title: '厂商', dataIndex: 'manufacturer', width: 120, ellipsis: true },
{ title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' },
{ title: '位置', dataIndex: 'location', width: 120, ellipsis: true },
{ title: '协议', dataIndex: 'protocol', width: 100 },
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => {
const st = statusMap[s] || { color: 'default', text: s || '-' };
return <Tag color={st.color}>{st.text}</Tag>;
}},
{ title: '最近数据时间', dataIndex: 'last_data_time', width: 170 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const, render: (_: any, record: any) => (
<Space>
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}></Button>
</Space>
)},
];
return (
<div>
{/* Stats Cards */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="设备总数" value={stats.total} prefix={<AppstoreOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="在线" value={stats.online} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="离线" value={stats.offline} prefix={<CloseCircleOutlined />} valueStyle={{ color: '#999' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="告警" value={stats.alarm} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
{/* Device Table */}
<Card size="small" title="设备列表" extra={
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}></Button>
}>
{/* Filters */}
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear placeholder="设备类型" style={{ width: 150 }}
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))}
onChange={v => handleFilterChange('device_type', v)}
/>
<Select
allowClear placeholder="设备分组" style={{ width: 150 }}
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))}
onChange={v => handleFilterChange('device_group', v)}
/>
<Select
allowClear placeholder="状态" style={{ width: 120 }}
options={[
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '告警', value: 'alarm' },
{ label: '维护', value: 'maintenance' },
]}
onChange={v => handleFilterChange('status', v)}
/>
<Input.Search
placeholder="搜索设备名称/编号" style={{ width: 220 }}
allowClear
onSearch={v => handleFilterChange('search', v)}
/>
</Space>
<Table
columns={columns} dataSource={data.items} rowKey="id"
loading={loading} size="small" scroll={{ x: 1500 }}
pagination={{
current: filters.page,
pageSize: filters.page_size,
total: data.total,
showSizeChanger: true,
showTotal: (total: number) => `${total} 台设备`,
onChange: handlePageChange,
}}
/>
</Card>
{/* Add/Edit Modal */}
<Modal
title={editingDevice ? '编辑设备' : '添加设备'}
open={showModal}
onCancel={() => { setShowModal(false); form.resetFields(); }}
onOk={() => form.submit()}
okText={editingDevice ? '保存' : '创建'}
cancelText="取消"
width={640}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="请输入设备名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="code" label="设备编号" rules={[{ required: true, message: '请输入设备编号' }]}>
<Input placeholder="请输入唯一设备编号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="device_type_id" label="设备类型">
<Select allowClear placeholder="选择设备类型"
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="device_group_id" label="设备分组">
<Select allowClear placeholder="选择设备分组"
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="model" label="型号">
<Input placeholder="设备型号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="manufacturer" label="厂商">
<Input placeholder="制造商" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="serial_number" label="序列号">
<Input placeholder="设备序列号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="rated_power" label="额定功率(kW)">
<InputNumber style={{ width: '100%' }} min={0} step={0.1} placeholder="额定功率" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="location" label="位置">
<Input placeholder="安装位置" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="protocol" label="通信协议">
<Select allowClear placeholder="选择协议" options={protocolOptions} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="collect_interval" label="采集间隔(秒)" initialValue={15}>
<InputNumber style={{ width: '100%' }} min={1} max={3600} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="is_active" label="启用" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="connection_params" label="连接参数(JSON)">
<Input.TextArea rows={3} placeholder='{"host": "192.168.1.100", "port": 502}' />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -7,9 +7,23 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8001',
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
// Force consistent pre-bundling of deps that have CJS/ESM interop issues
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/client',
'@react-three/fiber',
'@react-three/drei',
'@react-three/postprocessing',
'three',
],
// Ensure all deps are processed in a single pass
force: true,
},
})

213
scripts/backfill_data.py Normal file
View File

@@ -0,0 +1,213 @@
"""回填历史模拟能耗数据 - 过去30天逐小时数据"""
import asyncio
import math
import os
import random
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, "../backend")
# Allow override via env var (same pattern as other scripts)
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://tianpu:tianpu2026@localhost:5432/tianpu_ems",
)
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import text
# ---------------------------------------------------------------------------
# Device definitions
# ---------------------------------------------------------------------------
PV_IDS = [1, 2, 3] # 110 kW rated each
HP_IDS = [4, 5, 6, 7] # 35 kW rated each
METER_IDS = [8, 9, 10, 11]
DAYS = 30
HOURS_PER_DAY = 24
TOTAL_HOURS = DAYS * HOURS_PER_DAY # 720
# ---------------------------------------------------------------------------
# Curve generators (return kW for a given hour-of-day 0-23)
# ---------------------------------------------------------------------------
def pv_power(hour: int, rated: float = 110.0) -> float:
"""Solar bell curve: 0 at night, peak ~80-100 kW around noon."""
if hour < 6 or hour >= 18:
return 0.0
# sine curve from 6-18 with peak at 12
x = (hour - 6) / 12.0 * math.pi
base = math.sin(x) * rated * random.uniform(0.72, 0.92)
noise = base * random.uniform(-0.15, 0.15)
return max(0.0, base + noise)
def heat_pump_power(hour: int, rated: float = 35.0) -> float:
"""Heat pump load: base 15-25 kW, peaks morning 7-9 and evening 17-20.
Spring season so moderate."""
base = random.uniform(15, 25)
if 7 <= hour <= 9:
base += random.uniform(5, 10)
elif 17 <= hour <= 20:
base += random.uniform(5, 10)
elif 0 <= hour <= 5:
base -= random.uniform(3, 8)
base = min(base, rated)
noise = base * random.uniform(-0.20, 0.20)
return max(0.0, base + noise)
def meter_power(hour: int) -> float:
"""Building load: ~60 kW at night, ~100 kW during business hours."""
if 8 <= hour <= 18:
base = random.uniform(85, 115)
elif 6 <= hour <= 7 or 19 <= hour <= 21:
base = random.uniform(65, 85)
else:
base = random.uniform(45, 70)
noise = base * random.uniform(-0.15, 0.15)
return max(0.0, base + noise)
# ---------------------------------------------------------------------------
# Main backfill
# ---------------------------------------------------------------------------
async def backfill():
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=5)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = now - timedelta(days=DAYS)
print(f"Backfill range: {start.isoformat()} -> {now.isoformat()}")
print(f"Total hours: {TOTAL_HOURS}")
# ---- Collect hourly energy_data rows and per-device daily buckets ----
energy_rows = [] # list of dicts for bulk insert
# daily_buckets[device_id][date_str] = list of hourly values
daily_buckets: dict[int, dict[str, list[float]]] = {}
all_device_ids = PV_IDS + HP_IDS + METER_IDS
for did in all_device_ids:
daily_buckets[did] = {}
print("Generating hourly energy_data rows ...")
for h_offset in range(TOTAL_HOURS):
ts = start + timedelta(hours=h_offset)
hour = ts.hour
date_str = ts.strftime("%Y-%m-%d")
for did in PV_IDS:
val = round(pv_power(hour), 2)
energy_rows.append({
"device_id": did,
"timestamp": ts,
"data_type": "power",
"value": val,
"unit": "kW",
"quality": 100,
})
daily_buckets[did].setdefault(date_str, []).append(val)
for did in HP_IDS:
val = round(heat_pump_power(hour), 2)
energy_rows.append({
"device_id": did,
"timestamp": ts,
"data_type": "power",
"value": val,
"unit": "kW",
"quality": 100,
})
daily_buckets[did].setdefault(date_str, []).append(val)
for did in METER_IDS:
val = round(meter_power(hour), 2)
energy_rows.append({
"device_id": did,
"timestamp": ts,
"data_type": "power",
"value": val,
"unit": "kW",
"quality": 100,
})
daily_buckets[did].setdefault(date_str, []).append(val)
print(f" Generated {len(energy_rows)} energy_data rows")
# ---- Build daily summary rows ----
print("Computing daily summaries ...")
summary_rows = []
for did, dates in daily_buckets.items():
is_pv = did in PV_IDS
for date_str, values in dates.items():
total = round(sum(values), 2) # kWh (hourly power * 1h)
peak = round(max(values), 2)
min_p = round(min(values), 2)
avg_p = round(sum(values) / len(values), 2)
op_hours = sum(1 for v in values if v > 0)
cost = round(total * 0.85, 2)
carbon = round(total * 0.8843, 2)
summary_rows.append({
"device_id": did,
"date": datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc),
"energy_type": "electricity",
"total_consumption": 0.0 if is_pv else total,
"total_generation": total if is_pv else 0.0,
"peak_power": peak,
"min_power": min_p,
"avg_power": avg_p,
"operating_hours": float(op_hours),
"cost": cost,
"carbon_emission": carbon,
})
print(f" Generated {len(summary_rows)} daily summary rows")
# ---- Bulk insert ----
BATCH = 2000
async with session_factory() as session:
# Insert energy_data
print("Inserting energy_data ...")
insert_energy = text("""
INSERT INTO energy_data (device_id, timestamp, data_type, value, unit, quality)
VALUES (:device_id, :timestamp, :data_type, :value, :unit, :quality)
""")
for i in range(0, len(energy_rows), BATCH):
batch = energy_rows[i : i + BATCH]
await session.execute(insert_energy, batch)
done = min(i + BATCH, len(energy_rows))
print(f" energy_data: {done}/{len(energy_rows)}")
await session.commit()
print(" energy_data done.")
# Insert daily summaries
print("Inserting energy_daily_summary ...")
insert_summary = text("""
INSERT INTO energy_daily_summary
(device_id, date, energy_type, total_consumption, total_generation,
peak_power, min_power, avg_power, operating_hours, cost, carbon_emission)
VALUES
(:device_id, :date, :energy_type, :total_consumption, :total_generation,
:peak_power, :min_power, :avg_power, :operating_hours, :cost, :carbon_emission)
""")
for i in range(0, len(summary_rows), BATCH):
batch = summary_rows[i : i + BATCH]
await session.execute(insert_summary, batch)
done = min(i + BATCH, len(summary_rows))
print(f" daily_summary: {done}/{len(summary_rows)}")
await session.commit()
print(" daily_summary done.")
await engine.dispose()
print("Backfill complete!")
if __name__ == "__main__":
asyncio.run(backfill())