feat: add management web console

This commit is contained in:
Yoilun
2026-04-27 11:28:57 +08:00
parent b3672c564a
commit c4f9dab049
17 changed files with 2051 additions and 7 deletions

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>冷藏展示柜管理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

14
web/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "cold-display-guard-web",
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 23000",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 23000"
},
"devDependencies": {
"vite": "^5.0.0"
}
}

584
web/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,584 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
vite:
specifier: ^5.0.0
version: 5.4.21
packages:
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@rollup/rollup-android-arm-eabi@4.60.2':
resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.2':
resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.2':
resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.2':
resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.2':
resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.2':
resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.2':
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.2':
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.2':
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.2':
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.2':
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.2':
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.2':
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.2':
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.2':
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.2':
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.2':
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.2':
resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.2':
resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.2':
resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.2':
resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.2':
resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.2:
resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@rollup/rollup-android-arm-eabi@4.60.2':
optional: true
'@rollup/rollup-android-arm64@4.60.2':
optional: true
'@rollup/rollup-darwin-arm64@4.60.2':
optional: true
'@rollup/rollup-darwin-x64@4.60.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.2':
optional: true
'@rollup/rollup-freebsd-x64@4.60.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.2':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.2':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.2':
optional: true
'@rollup/rollup-openbsd-x64@4.60.2':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.2':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.2':
optional: true
'@types/estree@1.0.8': {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
fsevents@2.3.3:
optional: true
nanoid@3.3.11: {}
picocolors@1.1.1: {}
postcss@8.5.12:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.2:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.2
'@rollup/rollup-android-arm64': 4.60.2
'@rollup/rollup-darwin-arm64': 4.60.2
'@rollup/rollup-darwin-x64': 4.60.2
'@rollup/rollup-freebsd-arm64': 4.60.2
'@rollup/rollup-freebsd-x64': 4.60.2
'@rollup/rollup-linux-arm-gnueabihf': 4.60.2
'@rollup/rollup-linux-arm-musleabihf': 4.60.2
'@rollup/rollup-linux-arm64-gnu': 4.60.2
'@rollup/rollup-linux-arm64-musl': 4.60.2
'@rollup/rollup-linux-loong64-gnu': 4.60.2
'@rollup/rollup-linux-loong64-musl': 4.60.2
'@rollup/rollup-linux-ppc64-gnu': 4.60.2
'@rollup/rollup-linux-ppc64-musl': 4.60.2
'@rollup/rollup-linux-riscv64-gnu': 4.60.2
'@rollup/rollup-linux-riscv64-musl': 4.60.2
'@rollup/rollup-linux-s390x-gnu': 4.60.2
'@rollup/rollup-linux-x64-gnu': 4.60.2
'@rollup/rollup-linux-x64-musl': 4.60.2
'@rollup/rollup-openbsd-x64': 4.60.2
'@rollup/rollup-openharmony-arm64': 4.60.2
'@rollup/rollup-win32-arm64-msvc': 4.60.2
'@rollup/rollup-win32-ia32-msvc': 4.60.2
'@rollup/rollup-win32-x64-gnu': 4.60.2
'@rollup/rollup-win32-x64-msvc': 4.60.2
fsevents: 2.3.3
source-map-js@1.2.1: {}
vite@5.4.21:
dependencies:
esbuild: 0.21.5
postcss: 8.5.12
rollup: 4.60.2
optionalDependencies:
fsevents: 2.3.3

485
web/src/main.js Normal file
View File

@@ -0,0 +1,485 @@
import "./styles.css";
const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"];
const allRegions = [...zoneIds, "trash"];
const palette = {
r1c1: "#d92d20",
r1c2: "#b54708",
r1c3: "#4e5ba6",
r1c4: "#008a5a",
r2c1: "#0077a3",
r2c2: "#155eef",
r2c3: "#7f56d9",
r2c4: "#c11574",
trash: "#111827",
};
const state = {
config: null,
summary: null,
events: [],
activeTab: "calibration",
activeRegion: "r1c1",
polygons: Object.fromEntries(allRegions.map((id) => [id, []])),
image: null,
imageUrl: null,
status: "正在连接后端...",
};
const app = document.querySelector("#app");
app.innerHTML = `
<div class="shell">
<header class="header">
<div class="brand">
<div class="brand-mark">CD</div>
<div>
<div class="brand-title">冷藏展示柜管理</div>
<div class="brand-subtitle">标定、配置、事件数据</div>
</div>
</div>
<nav class="tabs">
<button data-tab="calibration">区域标定</button>
<button data-tab="events">事件数据</button>
<button data-tab="settings">运行配置</button>
</nav>
</header>
<main class="main">
<section class="status-line">
<span id="statusText"></span>
<button id="refreshAll" type="button">刷新</button>
</section>
<section id="calibrationView" class="view">
<section class="toolbar">
<label>
RTSP 地址
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<button id="saveConfig" type="button">保存配置</button>
<button id="captureSnapshot" type="button">抓取一帧</button>
<button id="saveCalibration" type="button">保存标定到配置</button>
</section>
<section class="calibration-grid">
<aside class="panel">
<div class="panel-title">区域</div>
<div id="regionList" class="region-list"></div>
<div class="button-stack">
<button id="undoPoint" type="button">撤销点</button>
<button id="clearRegion" type="button">清空当前区域</button>
<button id="loadConfigPolygons" type="button">载入当前配置区域</button>
</div>
</aside>
<section class="canvas-panel">
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
<aside class="panel">
<div class="panel-title">标定结果</div>
<div id="regionSummary" class="region-summary"></div>
</aside>
</section>
</section>
<section id="eventsView" class="view hidden">
<section class="metrics" id="metrics"></section>
<section class="panel">
<div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div>
</section>
</section>
<section id="settingsView" class="view hidden">
<section class="settings-grid">
<label>
Camera ID
<input id="cameraId" type="text">
</label>
<label>
时区
<input id="timezone" type="text">
</label>
<label>
最大放置秒数
<input id="maxDwell" type="number" min="1">
</label>
<label>
垃圾桶确认秒数
<input id="trashWindow" type="number" min="1">
</label>
</section>
<pre id="configPreview" class="config-preview"></pre>
</section>
</main>
</div>
`;
const els = {
statusText: document.querySelector("#statusText"),
canvas: document.querySelector("#canvas"),
regionList: document.querySelector("#regionList"),
rtspUrl: document.querySelector("#rtspUrl"),
cameraId: document.querySelector("#cameraId"),
timezone: document.querySelector("#timezone"),
maxDwell: document.querySelector("#maxDwell"),
trashWindow: document.querySelector("#trashWindow"),
configPreview: document.querySelector("#configPreview"),
regionSummary: document.querySelector("#regionSummary"),
metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"),
};
const ctx = els.canvas.getContext("2d");
function boot() {
wireEvents();
renderRegionList();
refreshAll();
}
function wireEvents() {
document.querySelectorAll(".tabs button").forEach((button) => {
button.addEventListener("click", () => setTab(button.dataset.tab));
});
document.querySelector("#refreshAll").addEventListener("click", refreshAll);
document.querySelector("#saveConfig").addEventListener("click", saveConfig);
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
document.querySelector("#clearRegion").addEventListener("click", clearRegion);
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint);
window.addEventListener("resize", drawCanvas);
}
async function refreshAll() {
try {
setStatus("正在读取配置和事件...");
const [config, summary, events] = await Promise.all([
apiJson("/api/manage/config"),
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=200"),
]);
state.config = config;
state.summary = summary;
state.events = events.items || [];
fillForm();
loadPolygonsFromConfig(false);
render();
setStatus("已连接后端 19080");
} catch (error) {
setStatus(`连接失败:${error.message}`);
}
}
async function saveConfig() {
try {
const payload = {
camera_id: els.cameraId.value.trim(),
timezone: els.timezone.value.trim(),
rtsp_url: els.rtspUrl.value.trim(),
thresholds: {
max_dwell_seconds: Number(els.maxDwell.value),
trash_confirmation_seconds: Number(els.trashWindow.value),
},
};
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
fillForm();
renderConfigPreview();
setStatus("配置已保存");
} catch (error) {
setStatus(`保存配置失败:${error.message}`);
}
}
async function captureSnapshot() {
try {
setStatus("正在从 RTSP 抓取一帧...");
const response = await fetch("/api/manage/snapshot", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({rtsp_url: els.rtspUrl.value.trim(), timeout_seconds: 12}),
});
if (!response.ok) {
const payload = await response.json();
throw new Error(payload.error || `HTTP ${response.status}`);
}
const blob = await response.blob();
if (state.imageUrl) {
URL.revokeObjectURL(state.imageUrl);
}
state.imageUrl = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
state.image = image;
els.canvas.width = image.naturalWidth;
els.canvas.height = image.naturalHeight;
drawCanvas();
setStatus(`已抓取 ${image.naturalWidth}x${image.naturalHeight}`);
};
image.src = state.imageUrl;
} catch (error) {
setStatus(`抓帧失败:${error.message}`);
}
}
async function saveCalibration() {
try {
const zones = zoneIds
.map((id) => ({id, polygon: state.polygons[id]}))
.filter((zone) => zone.polygon.length >= 3);
const trashPolygon = state.polygons.trash;
if (zones.length !== zoneIds.length) {
setStatus("8 个格口都标定后才能保存");
return;
}
if (trashPolygon.length < 3) {
setStatus("垃圾桶区域至少需要 3 个点");
return;
}
state.config = await apiJson("/api/manage/calibration", {
method: "PUT",
body: {zones, trash: {roi: trashPolygon}},
});
render();
setStatus("标定已保存到项目配置");
} catch (error) {
setStatus(`保存标定失败:${error.message}`);
}
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tabs button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === tab);
});
document.querySelector("#calibrationView").classList.toggle("hidden", tab !== "calibration");
document.querySelector("#eventsView").classList.toggle("hidden", tab !== "events");
document.querySelector("#settingsView").classList.toggle("hidden", tab !== "settings");
}
function fillForm() {
const config = state.config || {};
els.rtspUrl.value = config.stream?.rtsp_url || "";
els.cameraId.value = config.camera_id || "";
els.timezone.value = config.timezone || "";
els.maxDwell.value = config.thresholds?.max_dwell_seconds || 10800;
els.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120;
}
function loadPolygonsFromConfig(updateStatus = true) {
if (!state.config) {
return;
}
for (const zone of state.config.zones || []) {
if (zone.id && Array.isArray(zone.polygon)) {
state.polygons[zone.id] = zone.polygon.map(([x, y]) => ({x, y}));
}
}
if (Array.isArray(state.config.trash?.roi)) {
state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
}
render();
if (updateStatus) {
setStatus("已载入当前配置区域");
}
}
function render() {
renderRegionList();
drawCanvas();
renderRegionSummary();
renderMetrics();
renderEvents();
renderConfigPreview();
setTab(state.activeTab);
}
function renderRegionList() {
els.regionList.innerHTML = "";
for (const id of allRegions) {
const button = document.createElement("button");
button.type = "button";
button.textContent = `${id}${state.polygons[id].length >= 3 ? " ✓" : ""}`;
button.className = id === state.activeRegion ? "active" : "";
button.addEventListener("click", () => {
state.activeRegion = id;
render();
});
els.regionList.appendChild(button);
}
}
function addPoint(event) {
if (!state.image) {
setStatus("请先从 RTSP 抓取一帧");
return;
}
const rect = els.canvas.getBoundingClientRect();
const x = clamp((event.clientX - rect.left) / rect.width);
const y = clamp((event.clientY - rect.top) / rect.height);
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
render();
}
function undoPoint() {
state.polygons[state.activeRegion].pop();
render();
}
function clearRegion() {
state.polygons[state.activeRegion] = [];
render();
}
function drawCanvas() {
ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
if (state.image) {
ctx.drawImage(state.image, 0, 0, els.canvas.width, els.canvas.height);
} else {
ctx.fillStyle = "#121826";
ctx.fillRect(0, 0, els.canvas.width, els.canvas.height);
ctx.fillStyle = "#dbe3ea";
ctx.font = "22px system-ui";
ctx.textAlign = "center";
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
}
for (const id of allRegions) {
drawPolygon(id, state.polygons[id]);
}
}
function drawPolygon(id, points) {
if (!points.length) {
return;
}
const color = palette[id] || "#ffffff";
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = id === state.activeRegion ? 4 : 2;
ctx.beginPath();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
if (points.length >= 3) {
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.stroke();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(String(index + 1), x, y - 9);
ctx.fillStyle = color;
});
const first = points[0];
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
ctx.textAlign = "left";
ctx.fillText(id, first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.restore();
}
function renderRegionSummary() {
els.regionSummary.innerHTML = allRegions
.map((id) => {
const count = state.polygons[id].length;
return `<div><strong>${id}</strong><span>${count >= 3 ? `${count} 点,已标定` : `${count}`}</span></div>`;
})
.join("");
}
function renderMetrics() {
const metrics = state.summary?.metrics || {};
const cards = [
["事件总数", metrics.event_count ?? 0],
["违规事件", metrics.violation_count ?? 0],
["最新报警", metrics.latest_alert_time || "-"],
["事件文件", metrics.events_path || "-"],
];
els.metrics.innerHTML = cards.map(([label, value]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function renderEvents() {
if (!state.events.length) {
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
return;
}
els.eventsTable.innerHTML = `
<table>
<thead><tr><th>时间</th><th>事件</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<tbody>
${state.events
.slice()
.reverse()
.map((event) => `
<tr>
<td>${escapeHtml(event.ts || "")}</td>
<td>${escapeHtml(event.event || "")}</td>
<td>${escapeHtml(event.zone_id || "")}</td>
<td>${escapeHtml(event.batch_id || "")}</td>
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
</tr>
`)
.join("")}
</tbody>
</table>
`;
}
function renderConfigPreview() {
els.configPreview.textContent = JSON.stringify(state.config || {}, null, 2);
}
async function apiJson(path, options = {}) {
const request = {...options};
if (request.body && typeof request.body !== "string") {
request.headers = {"Content-Type": "application/json", ...(request.headers || {})};
request.body = JSON.stringify(request.body);
}
const response = await fetch(path, request);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function setStatus(message) {
state.status = message;
els.statusText.textContent = message;
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
boot();

302
web/src/styles.css Normal file
View File

@@ -0,0 +1,302 @@
:root {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1d2939;
background: #f3f5f7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input {
font: inherit;
}
button {
border: 1px solid #c8d0d8;
background: #ffffff;
color: #1d2939;
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
}
button:hover {
background: #eef3f7;
}
input {
width: 100%;
border: 1px solid #b6c0ca;
border-radius: 6px;
padding: 9px 10px;
background: #ffffff;
}
label {
display: grid;
gap: 6px;
color: #475467;
font-size: 13px;
}
.shell {
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px 24px;
background: #ffffff;
border-bottom: 1px solid #d8dee5;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: #155eef;
color: #ffffff;
font-weight: 700;
}
.brand-title {
font-size: 18px;
font-weight: 700;
}
.brand-subtitle {
margin-top: 2px;
color: #667085;
font-size: 13px;
}
.tabs {
display: flex;
gap: 8px;
}
.tabs button.active {
background: #155eef;
border-color: #155eef;
color: #ffffff;
}
.main {
padding: 16px;
}
.status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
color: #475467;
}
.toolbar {
display: grid;
grid-template-columns: minmax(360px, 1fr) auto auto auto;
gap: 12px;
align-items: end;
margin-bottom: 12px;
padding: 12px;
background: #ffffff;
border: 1px solid #d8dee5;
border-radius: 8px;
}
.calibration-grid {
display: grid;
grid-template-columns: 220px minmax(520px, 1fr) 260px;
gap: 12px;
min-height: calc(100vh - 190px);
}
.panel,
.canvas-panel,
.config-preview {
background: #ffffff;
border: 1px solid #d8dee5;
border-radius: 8px;
}
.panel {
padding: 12px;
}
.panel-title {
margin-bottom: 10px;
font-size: 15px;
font-weight: 700;
}
.region-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.region-list button.active {
background: #155eef;
border-color: #155eef;
color: #ffffff;
}
.button-stack {
display: grid;
gap: 8px;
margin-top: 14px;
}
.canvas-panel {
display: flex;
min-width: 0;
min-height: 0;
align-items: center;
justify-content: center;
overflow: hidden;
background: #121826;
}
canvas {
width: 100%;
height: 100%;
object-fit: contain;
cursor: crosshair;
}
.region-summary {
display: grid;
gap: 8px;
}
.region-summary div {
display: flex;
justify-content: space-between;
gap: 8px;
padding-bottom: 7px;
border-bottom: 1px solid #edf1f5;
}
.region-summary span {
color: #667085;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.metric {
display: grid;
gap: 8px;
padding: 14px;
background: #ffffff;
border: 1px solid #d8dee5;
border-radius: 8px;
}
.metric span {
color: #667085;
font-size: 13px;
}
.metric strong {
overflow-wrap: anywhere;
font-size: 18px;
}
.events-table {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px;
border-bottom: 1px solid #edf1f5;
text-align: left;
white-space: nowrap;
}
th {
color: #475467;
font-size: 13px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: 12px;
margin-bottom: 12px;
padding: 12px;
background: #ffffff;
border: 1px solid #d8dee5;
border-radius: 8px;
}
.config-preview {
margin: 0;
padding: 12px;
min-height: 320px;
overflow: auto;
font-size: 12px;
}
.empty {
padding: 24px;
color: #667085;
}
.hidden {
display: none;
}
@media (max-width: 1100px) {
.header,
.toolbar {
grid-template-columns: 1fr;
}
.header {
align-items: stretch;
flex-direction: column;
}
.tabs {
flex-wrap: wrap;
}
.calibration-grid {
grid-template-columns: 1fr;
}
.metrics,
.settings-grid {
grid-template-columns: 1fr;
}
}

14
web/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: "127.0.0.1",
port: 23000,
proxy: {
"/api": {
target: "http://127.0.0.1:19080",
changeOrigin: true,
},
},
},
});