first commit

This commit is contained in:
feie9456 2026-01-05 11:09:24 +08:00
commit 8f673e0095
47 changed files with 4317 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="postgresql://feie9454:zjh94544549OK%3F@100.64.0.5:5432/ssd"

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

323
bun.lock Normal file
View File

@ -0,0 +1,323 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "sound-speed-determination",
"dependencies": {
"vue": "^3.5.24",
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.97.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.10.2", "", {}, "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.53" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w=="],
"@volar/language-core": ["@volar/language-core@2.4.27", "", { "dependencies": { "@volar/source-map": "2.4.27" } }, "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ=="],
"@volar/source-map": ["@volar/source-map@2.4.27", "", {}, "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg=="],
"@volar/typescript": ["@volar/typescript@2.4.27", "", { "dependencies": { "@volar/language-core": "2.4.27", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.26", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.26", "", { "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.26", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.26", "@vue/compiler-dom": "3.5.26", "@vue/compiler-ssr": "3.5.26", "@vue/shared": "3.5.26", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.26", "", { "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw=="],
"@vue/language-core": ["@vue/language-core@3.2.1", "", { "dependencies": { "@volar/language-core": "2.4.27", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-g6oSenpnGMtpxHGAwKuu7HJJkNZpemK/zg3vZzZbJ6cnnXq1ssxuNrXSsAHYM3NvH8p4IkTw+NLmuxyeYz4r8A=="],
"@vue/reactivity": ["@vue/reactivity@3.5.26", "", { "dependencies": { "@vue/shared": "3.5.26" } }, "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.26", "", { "dependencies": { "@vue/reactivity": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.26", "", { "dependencies": { "@vue/reactivity": "3.5.26", "@vue/runtime-core": "3.5.26", "@vue/shared": "3.5.26", "csstype": "^3.2.3" } }, "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.26", "", { "dependencies": { "@vue/compiler-ssr": "3.5.26", "@vue/shared": "3.5.26" }, "peerDependencies": { "vue": "3.5.26" } }, "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA=="],
"@vue/shared": ["@vue/shared@3.5.26", "", {}, "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A=="],
"@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="],
"alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"entities": ["entities@7.0.0", "", {}, "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"sass": ["sass@1.97.1", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A=="],
"sass-embedded": ["sass-embedded@1.97.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.97.1", "sass-embedded-android-arm": "1.97.1", "sass-embedded-android-arm64": "1.97.1", "sass-embedded-android-riscv64": "1.97.1", "sass-embedded-android-x64": "1.97.1", "sass-embedded-darwin-arm64": "1.97.1", "sass-embedded-darwin-x64": "1.97.1", "sass-embedded-linux-arm": "1.97.1", "sass-embedded-linux-arm64": "1.97.1", "sass-embedded-linux-musl-arm": "1.97.1", "sass-embedded-linux-musl-arm64": "1.97.1", "sass-embedded-linux-musl-riscv64": "1.97.1", "sass-embedded-linux-musl-x64": "1.97.1", "sass-embedded-linux-riscv64": "1.97.1", "sass-embedded-linux-x64": "1.97.1", "sass-embedded-unknown-all": "1.97.1", "sass-embedded-win32-arm64": "1.97.1", "sass-embedded-win32-x64": "1.97.1" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-wH3CbOThHYGX0bUyqFf7laLKyhVWIFc2lHynitkqMIUCtX2ixH9mQh0bN7+hkUu5BFt/SXvEMjFbkEbBMpQiSQ=="],
"sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.97.1", "", { "dependencies": { "sass": "1.97.1" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-0au5gUNibfob7W/g+ycBx74O22CL8vwHiZdEDY6J0uzMkHPiSJk//h0iRf5AUnMArFHJjFd3urIiQIaoRKYa1Q=="],
"sass-embedded-android-arm": ["sass-embedded-android-arm@1.97.1", "", { "os": "android", "cpu": "arm" }, "sha512-B5dlv4utJ+yC8ZpBeWTHwSZPVKRlqA8pcaD0FAzeNm/DelIFgQUQtt0UwgYoAI6wDIiie5uSVpMK9l2DaCbiBQ=="],
"sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.97.1", "", { "os": "android", "cpu": "arm64" }, "sha512-h62DmOiS2Jn87s8+8GhJcMerJnTKa1IsIa9iIKjLiqbAvBDKCGUs027RugZkM+Zx7I+vhPq86PUXBYZ9EkRxdw=="],
"sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.97.1", "", { "os": "android", "cpu": "none" }, "sha512-tGup88vgaXPnUHEgDMujrt5rfYadvkiVjRb/45FJTx2hQFoGVbmUXz5XqUFjIIbEjQ3kAJqp86A2jy11s43UiQ=="],
"sass-embedded-android-x64": ["sass-embedded-android-x64@1.97.1", "", { "os": "android", "cpu": "x64" }, "sha512-CAzKjjzu90LZduye2O9+UGX1oScMyF5/RVOa5CxACKALeIS+3XL3LVdV47kwKPoBv5B1aFUvGLscY0CR7jBAbg=="],
"sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.97.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tyDzspzh5PbqdAFGtVKUXuf0up6Lff3c1U8J7+4Y7jW6AWRBnq95vTzIIxfnNifGCTI2fW5e7GAZpYygKpNwcw=="],
"sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.97.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-FMrRuSPI2ICt2M2SYaLbiG4yxn86D6ae+XtrRdrrBMhWprAcB7Iyu67bgRzZkipMZNIKKeTR7EUvJHgZzi5ixQ=="],
"sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.97.1", "", { "os": "linux", "cpu": "arm" }, "sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ=="],
"sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.97.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-im80gfDWRivw9Su3r3YaZmJaCATcJgu3CsCSLodPk1b1R2+X/E12zEQayvrl05EGT9PDwTtuiqKgS4ND4xjwVg=="],
"sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.97.1", "", { "os": "linux", "cpu": "arm" }, "sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA=="],
"sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.97.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A=="],
"sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.97.1", "", { "os": "linux", "cpu": "none" }, "sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg=="],
"sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.97.1", "", { "os": "linux", "cpu": "x64" }, "sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA=="],
"sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.97.1", "", { "os": "linux", "cpu": "none" }, "sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ=="],
"sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.97.1", "", { "os": "linux", "cpu": "x64" }, "sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg=="],
"sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.97.1", "", { "dependencies": { "sass": "1.97.1" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ=="],
"sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.97.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-L5j7J6CbZgHGwcfVedMVpM3z5MYeighcyZE8GF2DVmjWzZI3JtPKNY11wNTD/P9o1Uql10YPOKhGH0iWIXOT7Q=="],
"sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.97.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rfaZAKXU8cW3E7gvdafyD6YtgbEcsDeT99OEiHXRT0UGFuXT8qCOjpAwIKaOA3XXr2d8S42xx6cXcaZ1a+1fgw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.26", "", { "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", "@vue/runtime-dom": "3.5.26", "@vue/server-renderer": "3.5.26", "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA=="],
"vue-tsc": ["vue-tsc@3.2.1", "", { "dependencies": { "@volar/typescript": "2.4.27", "@vue/language-core": "3.2.1" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-I23Rk8dkQfmcSbxDO0dmg9ioMLjKA1pjlU3Lz6Jfk2pMGu3Uryu9810XkcZH24IzPbhzPCnkKo2rEMRX0skSrw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
}
}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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.ts"></script>
</body>
</html>

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "sound-speed-determination",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.97.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

973
src/App.vue Normal file
View File

@ -0,0 +1,973 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import DeterminationMachine from './components/DeterminationMachine.vue';
//@ts-ignore
import SilverKnob from './components/SilverKnob.vue';
import Oscilloscope from './components/Oscilloscope.vue';
import SignalGen from './components/SignalGen.vue';
import Notebook from './components/Notebook.vue';
import ea from './assets/sounds';
import guidePng from './assets/入门引导.webp';
const distance = ref(20);
// Signal generator frequency (kHz)
const signalFrequencyKHz = ref(35.702);
// =========================
// POST /api/tracker
// =========================
const trackerSessionId = (() => {
try {
const key = 'sound-speed:tracker-session';
const existed = window.localStorage.getItem(key);
if (existed) return existed;
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(key, id);
return id;
} catch {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
})();
const postTracker = (event: string, data?: unknown) => {
const body = JSON.stringify({
event,
data,
ts: Date.now(),
sessionId: trackerSessionId,
step: currentStepTitle.value,
});
try {
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
const blob = new Blob([body], { type: 'application/json' });
// @ts-ignore
navigator.sendBeacon('/api/tracker', blob);
return;
}
} catch {
// ignore
}
fetch('/api/tracker', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* ignore */ });
};
const makeThrottle = <T extends any[]>(fn: (...args: T) => void, ms: number) => {
let last = 0;
let timer: number | null = null;
let pending: T | null = null;
return (...args: T) => {
const now = Date.now();
const remain = ms - (now - last);
if (remain <= 0) {
last = now;
fn(...args);
return;
}
pending = args;
if (timer !== null) return;
timer = window.setTimeout(() => {
timer = null;
last = Date.now();
if (pending) fn(...pending);
pending = null;
}, remain);
};
};
const trackDistanceChange = makeThrottle((v: number) => {
postTracker('transducer_distance_change', { distanceMm: v });
}, 300);
const onDistanceChange = (v: number) => {
trackDistanceChange(v);
scheduleUpdateDmRightCablePaths();
};
const trackFrequencyChange = makeThrottle((v: number) => {
postTracker('signal_frequency_change', { frequencyKHz: v });
}, 300);
watch(signalFrequencyKHz, (v, prev) => {
//
if (prev === undefined) return;
trackFrequencyChange(v);
});
//
interface InstrumentState {
left: number; //
bottom: number; //
baseScale: number; //
zIndex: number; //
}
const machine = ref<InstrumentState>({ left: 35, bottom: 40, baseScale: 0.8, zIndex: 0 });
const oscilloscope = ref<InstrumentState>({ left: 10, bottom: 23, baseScale: 1.2, zIndex: 0 });
const signalGen = ref<InstrumentState>({ left: 65, bottom: 17, baseScale: 0.95, zIndex: 0 });
const notebook = ref<InstrumentState>({ left: 12, bottom: 40, baseScale: 0.9, zIndex: 0 });
// bottomzIndexbottomzIndex
const getAutoZIndex = (instrument: InstrumentState) => {
// bottom使bottom=0zIndex
return Math.round(1000 - instrument.bottom * 10);
};
// bottomscale
// bottom: 0-100%, scale: 1.5-0.5
const getPerspectiveScale = (instrument: InstrumentState) => {
// bottomscale
const perspectiveFactor = 1.5 - (instrument.bottom / 100) * 1.0; // bottom=01.5, bottom=1000.5
return instrument.baseScale * perspectiveFactor;
};
const machineStyle = computed(() => ({
left: `${machine.value.left}%`,
bottom: `${machine.value.bottom}%`,
transform: `scale(${getPerspectiveScale(machine.value)})`,
zIndex: getAutoZIndex(machine.value)
}));
const oscilloscopeStyle = computed(() => ({
left: `${oscilloscope.value.left}%`,
bottom: `${oscilloscope.value.bottom}%`,
transform: `scale(${getPerspectiveScale(oscilloscope.value)})`,
zIndex: getAutoZIndex(oscilloscope.value)
}));
const signalGenStyle = computed(() => ({
left: `${signalGen.value.left}%`,
bottom: `${signalGen.value.bottom}%`,
transform: `scale(${getPerspectiveScale(signalGen.value)})`,
zIndex: getAutoZIndex(signalGen.value)
}));
const notebookStyle = computed(() => ({
left: `${notebook.value.left}%`,
bottom: `${notebook.value.bottom}%`,
transform: `scale(${getPerspectiveScale(notebook.value)})`,
zIndex: getAutoZIndex(notebook.value)
}));
//
interface DragState {
isDragging: boolean;
startX: number;
startY: number;
startLeft: number;
startBottom: number;
}
const dragState = ref<DragState>({
isDragging: false,
startX: 0,
startY: 0,
startLeft: 0,
startBottom: 0
});
const currentInstrument = ref<InstrumentState | null>(null);
const startDrag = (instrument: InstrumentState, event: PointerEvent) => {
hideTooltip();
dragState.value = {
isDragging: true,
startX: event.clientX,
startY: event.clientY,
startLeft: instrument.left,
startBottom: instrument.bottom
};
currentInstrument.value = instrument;
//
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (tooltip.visible && tooltipTargetEl.value) {
updateTooltipPosition(tooltipTargetEl.value);
}
if (!dragState.value.isDragging || !currentInstrument.value) return;
const tableWidth = 1600;
const tableHeight = 900;
//
const deltaX = event.clientX - dragState.value.startX;
const deltaY = event.clientY - dragState.value.startY;
//
const deltaLeftPercent = (deltaX / tableWidth) * 100;
const deltaBottomPercent = -(deltaY / tableHeight) * 100; // Ybottom
//
currentInstrument.value.left = Math.max(0, Math.min(100, dragState.value.startLeft + deltaLeftPercent));
currentInstrument.value.bottom = Math.max(0, Math.min(55, dragState.value.startBottom + deltaBottomPercent));
scheduleUpdateCablePaths();
};
const onPointerUp = () => {
dragState.value.isDragging = false;
currentInstrument.value = null;
};
// =========================
//
// =========================
const showGuide = ref(true);
const guideImgRef = ref<HTMLImageElement | null>(null);
const closeGuide = () => {
showGuide.value = false;
};
const onGuideClick = (e: MouseEvent) => {
const img = guideImgRef.value;
if (!img) return;
const rect = img.getBoundingClientRect();
// 1/8
const x = e.clientX;
const y = e.clientY;
const inImg = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (!inImg) return;
const yIn = y - rect.top;
if (yIn >= rect.height * 7 / 8) {
closeGuide();
}
};
// =========================
// /
// =========================
const oscStatus = reactive({
vdch1: 320,
vdch2: 320,
currentModeIndex: 0,
xyMode: false,
power: false,
});
const onOscSettings = (payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
oscStatus.vdch1 = payload.vdch1;
oscStatus.vdch2 = payload.vdch2;
oscStatus.currentModeIndex = payload.currentModeIndex;
oscStatus.xyMode = payload.xyMode;
oscStatus.power = payload.power;
};
const trackOscSettings = makeThrottle((p: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
postTracker('osc_settings_change', p);
}, 300);
watch(() => ({
vdch1: oscStatus.vdch1,
vdch2: oscStatus.vdch2,
currentModeIndex: oscStatus.currentModeIndex,
xyMode: oscStatus.xyMode,
power: oscStatus.power,
}), (p) => {
trackOscSettings(p);
}, { deep: true });
const lissajousLineStreak = ref(0);
const lastLineK = ref<number | null>(null);
const onLissajousLine = (payload: { k: number; phaseRadTotal: number }) => {
// X-Y
if (!oscStatus.xyMode) return;
if (lastLineK.value === null) {
lissajousLineStreak.value = 1;
lastLineK.value = payload.k;
return;
}
// k 1 1
if (payload.k === lastLineK.value + 1) {
lissajousLineStreak.value += 1;
} else {
lissajousLineStreak.value = 1;
}
lastLineK.value = payload.k;
};
watch(lissajousLineStreak, (v, prev) => {
if (v !== prev) postTracker('lissajous_line_streak', { streak: v });
});
// =========================
// 线仿pin 线
// =========================
type PinId =
| 'osc:ch1'
| 'osc:ch2'
| 'sg:tx-wave'
| 'sg:tx-transducer'
| 'sg:rx-wave'
| 'sg:rx-transducer'
| 'dm:left'
| 'dm:right';
type WireKey =
| 'txWave_to_ch1'
| 'txTransducer_to_left'
| 'rxWave_to_ch2'
| 'rxTransducer_to_right';
// App.vue 4 线
const wiringConnected = reactive<Record<WireKey, boolean>>({
txWave_to_ch1: false,
txTransducer_to_left: false,
rxWave_to_ch2: false,
rxTransducer_to_right: false,
});
// =========================
// document.title
// =========================
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
const stage1Done = computed(() => {
return wiringConnected.txWave_to_ch1
&& wiringConnected.txTransducer_to_left
&& wiringConnected.rxWave_to_ch2
&& wiringConnected.rxTransducer_to_right;
});
const stage2Done = computed(() => {
return oscStatus.power
&& inRange(oscStatus.vdch1, 140, 220)
&& inRange(oscStatus.vdch2, 140, 220)
&& oscStatus.currentModeIndex === 2;
});
const stage3Done = computed(() => inRange(signalFrequencyKHz.value, 35.98, 36.02));
const stage4Done = computed(() => {
return oscStatus.xyMode && lissajousLineStreak.value >= 4;
});
const currentStepTitle = computed(() => {
if (!stage1Done.value) return '步骤一:仪器接线';
if (!stage2Done.value) return '步骤二:调整示波器设置';
if (!stage3Done.value) return '步骤三:找到固有频率';
if (!stage4Done.value) return '步骤四:相位比较法测声速';
return '全部完成';
});
watch(currentStepTitle, (t) => {
document.title = `声速测量实验 - ${t}`;
}, { immediate: true });
const tableRef = ref<HTMLDivElement | null>(null);
// =========================
// Tooltip
// =========================
type TooltipSide = 'right' | 'left';
const tooltip = reactive({
visible: false,
text: '',
x: 0,
y: 0,
side: 'right' as TooltipSide,
});
const tooltipTargetEl = ref<HTMLElement | null>(null);
const hideTooltip = () => {
tooltip.visible = false;
tooltipTargetEl.value = null;
};
const updateTooltipPosition = (el: HTMLElement) => {
const table = tableRef.value;
if (!table) return;
const tableRect = table.getBoundingClientRect();
const r = el.getBoundingClientRect();
const margin = 10;
const approxBubbleW = 280; //
//
const anchorY = r.top - tableRect.top - margin;
const rightX = r.right - tableRect.left + margin;
const leftX = r.left - tableRect.left - margin;
const hasRoomOnRight = rightX + approxBubbleW <= tableRect.width;
tooltip.side = hasRoomOnRight ? 'right' : 'left';
tooltip.x = hasRoomOnRight ? rightX : leftX;
// y
tooltip.y = Math.max(10, Math.min(tableRect.height - 10, anchorY));
};
const showTooltipForEl = (el: HTMLElement) => {
const raw = (el.dataset.tooltip ?? '').trim();
// "\\n"
const text = raw.replace(/\\n/g, '\n');
if (!text) return;
tooltipTargetEl.value = el;
tooltip.text = text;
tooltip.visible = true;
updateTooltipPosition(el);
};
const findTooltipEl = (target: EventTarget | null): HTMLElement | null => {
if (!target) return null;
const t = target as HTMLElement;
if (!t?.closest) return null;
const el = t.closest<HTMLElement>('[data-tooltip]');
if (!el) return null;
const table = tableRef.value;
if (table && !table.contains(el)) return null;
return el;
};
const onTooltipPointerOver = (e: PointerEvent) => {
if (showGuide.value) return;
const el = findTooltipEl(e.target);
if (!el) return;
if (tooltipTargetEl.value === el && tooltip.visible) return;
showTooltipForEl(el);
};
const onTooltipPointerOut = (e: PointerEvent) => {
const current = tooltipTargetEl.value;
if (!current) return;
const to = e.relatedTarget as HTMLElement | null;
if (to && (current === to || current.contains(to))) return;
const nextEl = findTooltipEl(to);
if (nextEl) {
showTooltipForEl(nextEl);
return;
}
hideTooltip();
};
const tooltipStyle = computed(() => {
const base: Record<string, string> = {
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
};
base.transform = tooltip.side === 'right' ? 'translate(0, -50%)' : 'translate(-100%, -50%)';
return base;
});
const pinEls = new Map<PinId, HTMLElement>();
const selectedPin = ref<PinId | null>(null);
const syncPinConnectingClass = () => {
const selected = selectedPin.value;
for (const [id, el] of pinEls) {
el.classList.toggle('pin-connecting', selected === id);
}
};
type Cable = {
key: WireKey;
a: PinId;
b: PinId;
d: string;
};
const cables = ref<Cable[]>([]);
let cablesRaf: number | null = null;
let pendingCableUpdateAll = false;
let pendingCableUpdatePins: Set<PinId> | null = null;
const flushCablePathUpdate = () => {
const doAll = pendingCableUpdateAll;
const pins = pendingCableUpdatePins;
pendingCableUpdateAll = false;
pendingCableUpdatePins = null;
if (doAll || !pins) {
updateCablePaths();
} else {
updateCablePaths(pins);
}
};
const scheduleUpdateCablePaths = () => {
pendingCableUpdateAll = true;
pendingCableUpdatePins = null;
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
const scheduleUpdateCablePathsForPins = (pins: Iterable<PinId>) => {
if (pendingCableUpdateAll) return;
if (!pendingCableUpdatePins) pendingCableUpdatePins = new Set<PinId>();
for (const p of pins) pendingCableUpdatePins.add(p);
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
let dmRightCableLast = 0;
let dmRightCableTimer: number | null = null;
const scheduleUpdateDmRightCablePaths = () => {
const now = Date.now();
const remain = 1000 - (now - dmRightCableLast);
if (remain <= 0) {
dmRightCableLast = now;
scheduleUpdateCablePathsForPins(['dm:right']);
return;
}
if (dmRightCableTimer !== null) return;
dmRightCableTimer = window.setTimeout(() => {
dmRightCableTimer = null;
dmRightCableLast = Date.now();
scheduleUpdateCablePathsForPins(['dm:right']);
}, remain);
};
const normalizePair = (a: PinId, b: PinId) => {
return a < b ? `${a}|${b}` : `${b}|${a}`;
};
const REQUIRED: Array<{ a: PinId; b: PinId; key: WireKey }> = [
{ a: 'sg:tx-wave', b: 'osc:ch1', key: 'txWave_to_ch1' },
{ a: 'sg:tx-transducer', b: 'dm:left', key: 'txTransducer_to_left' },
{ a: 'sg:rx-wave', b: 'osc:ch2', key: 'rxWave_to_ch2' },
{ a: 'sg:rx-transducer', b: 'dm:right', key: 'rxTransducer_to_right' },
];
const requiredByPair = new Map<string, WireKey>();
for (const c of REQUIRED) {
requiredByPair.set(normalizePair(c.a, c.b), c.key);
}
const registerPin = (payload: { id: PinId; el: HTMLElement }) => {
pinEls.set(payload.id, payload.el);
// pin
payload.el.classList.toggle('pin-connecting', selectedPin.value === payload.id);
if (payload.id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const unregisterPin = (id: PinId) => {
pinEls.delete(id);
if (id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const addCableIfNeeded = (key: WireKey, a: PinId, b: PinId) => {
if (cables.value.some((c) => c.key === key)) return;
cables.value.push({ key, a, b, d: '' });
scheduleUpdateCablePaths();
};
const alertWiringError = () => {
window.alert('接线错误:请按接线示意连接对应端口。');
};
const onPinClick = (id: PinId) => {
ea.play('线缆插入')
postTracker('pin_click', { pinId: id, hasSelected: selectedPin.value !== null });
if (selectedPin.value === null) {
selectedPin.value = id;
syncPinConnectingClass();
return;
}
const first = selectedPin.value;
selectedPin.value = null;
syncPinConnectingClass();
if (first === id) return;
const key = requiredByPair.get(normalizePair(first, id));
if (!key) {
postTracker('wire_error', { a: first, b: id });
alertWiringError();
return;
}
// 线
const was = wiringConnected[key];
wiringConnected[key] = true;
addCableIfNeeded(key, first, id);
// 线 DOM/
scheduleUpdateCablePaths();
if (!was) {
postTracker('wire_connected', { key, a: first, b: id });
}
};
const getPinCenterInTable = (pin: HTMLElement) => {
const table = tableRef.value;
if (!table) return null;
const tableRect = table.getBoundingClientRect();
const r = pin.getBoundingClientRect();
const x = r.left + r.width / 2 - tableRect.left;
const y = r.top + r.height / 2 - tableRect.top;
return { x, y };
};
const buildSagCablePath = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.hypot(dx, dy);
const sag = Math.min(220, Math.max(50, dist * 0.18));
const c1x = p1.x + dx * 0.25;
const c1y = p1.y + sag;
const c2x = p1.x + dx * 0.75;
const c2y = p2.y + sag;
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
};
const updateCablePaths = (onlyPins?: Set<PinId>) => {
for (const cable of cables.value) {
if (onlyPins && !onlyPins.has(cable.a) && !onlyPins.has(cable.b)) continue;
const aEl = pinEls.get(cable.a);
const bEl = pinEls.get(cable.b);
if (!aEl || !bEl) {
cable.d = '';
continue;
}
const p1 = getPinCenterInTable(aEl);
const p2 = getPinCenterInTable(bEl);
if (!p1 || !p2) {
cable.d = '';
continue;
}
cable.d = buildSagCablePath(p1, p2);
}
};
onMounted(() => {
scheduleUpdateCablePaths();
window.addEventListener('resize', scheduleUpdateCablePaths);
});
onBeforeUnmount(() => {
if (cablesRaf !== null) cancelAnimationFrame(cablesRaf);
window.removeEventListener('resize', scheduleUpdateCablePaths);
if (dmRightCableTimer !== null) window.clearTimeout(dmRightCableTimer);
});
</script>
<template>
<div ref="tableRef" class="table" @pointermove="onPointerMove"
@pointerover="onTooltipPointerOver" @pointerout="onTooltipPointerOut"
@pointerup="onPointerUp" @pointerleave="onPointerUp(); hideTooltip()">
<Transition name="guide">
<div v-if="showGuide" class="guide-overlay" @click="onGuideClick">
<img ref="guideImgRef" class="guide-img" :src="guidePng" alt="入门引导" draggable="false" />
</div>
</Transition>
<div v-if="tooltip.visible" class="tooltip" :class="`tooltip--${tooltip.side}`" :style="tooltipStyle">
<div class="tooltip-inner">{{ tooltip.text }}</div>
</div>
<svg class="cables" :width="1600" :height="900" viewBox="0 0 1600 900"
aria-hidden="true">
<defs>
<filter id="cableShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2"
flood-color="rgba(0,0,0,0.55)" />
</filter>
</defs>
<g filter="url(#cableShadow)">
<g v-for="c in cables" :key="c.key">
<path v-if="c.d" :d="c.d" class="cable-base" />
<path v-if="c.d" :d="c.d" class="cable-highlight" />
</g>
</g>
</svg>
<div class="instrument-wrapper" :style="machineStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(machine, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<DeterminationMachine v-model="distance" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" @change="onDistanceChange" />
</div>
<div class="instrument-wrapper" :style="notebookStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(notebook, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Notebook class="machine" :wiring="wiringConnected"
:osc="{ vdch1: oscStatus.vdch1, vdch2: oscStatus.vdch2, currentModeIndex: oscStatus.currentModeIndex, xyMode: oscStatus.xyMode, power: oscStatus.power }"
:signal-frequency-k-hz="signalFrequencyKHz"
:lissajous-line-streak="lissajousLineStreak" />
</div>
<div class="instrument-wrapper" :style="oscilloscopeStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(oscilloscope, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Oscilloscope :distance="distance" :frequency="signalFrequencyKHz * 1000"
class="machine" @register-pin="registerPin"
@unregister-pin="unregisterPin" @pin-click="onPinClick"
@settings="onOscSettings" @lissajous-line="onLissajousLine" />
</div>
<div class="instrument-wrapper" :style="signalGenStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(signalGen, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<SignalGen v-model="signalFrequencyKHz" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" />
</div>
</div>
</template>
<style scoped>
.table {
width: 1700px;
height: 900px;
margin: 0 auto;
position: relative;
overflow: hidden;
background-image: url(./assets/桌面.webp);
background-size: cover;
background-position: center;
user-select: none;
}
.guide-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
}
.guide-img {
width: 100vw;
height: 100vh;
object-fit: contain;
user-select: none;
}
.guide-enter-active,
.guide-leave-active {
transition: opacity 0.35s ease, transform 0.35s ease;
}
.guide-enter-from,
.guide-leave-to {
opacity: 0;
transform: translateY(10px);
}
.cables {
position: absolute;
inset: 0;
z-index: 850;
pointer-events: none;
}
.cable-base {
fill: none;
stroke: rgba(35, 35, 35, 0.95);
stroke-width: 10;
stroke-linecap: round;
stroke-linejoin: round;
}
.cable-highlight {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.instrument-wrapper {
position: absolute;
filter: drop-shadow(0 10px 10px black);
transition: filter 0.2s;
}
.machine {
pointer-events: auto;
}
.drag-handle {
touch-action: none;
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 24px;
background: linear-gradient(135deg, rgba(100, 100, 100, 0.9), rgba(60, 60, 60, 0.95));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.2);
opacity: 0.7;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
}
.drag-handle:hover {
opacity: 1;
background: linear-gradient(135deg, rgba(120, 120, 120, 0.95), rgba(80, 80, 80, 1));
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5), inset 0 1px 2px rgba(255, 255, 255, 0.3);
transform: translateX(-50%) scale(1.1);
}
.drag-handle:active {
cursor: grabbing;
background: linear-gradient(135deg, rgba(80, 80, 80, 1), rgba(50, 50, 50, 1));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6), inset 0 1px 2px rgba(255, 255, 255, 0.2);
}
.drag-handle svg {
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
}
.instrument-wrapper:hover .drag-handle {
opacity: 0.9;
}
.tooltip {
position: absolute;
z-index: 9000;
pointer-events: none;
max-width: 280px;
}
.tooltip-inner {
background:
linear-gradient(rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.96)),
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.035) 0px,
rgba(0, 0, 0, 0.035) 1px,
rgba(0, 0, 0, 0.0) 10px,
rgba(0, 0, 0, 0.0) 18px
);
color: rgba(20, 20, 20, 0.95);
border: 2px solid rgba(20, 20, 20, 0.70);
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.3;
box-shadow: 4px 5px 0 rgba(0, 0, 0, 0.18), 0 12px 18px rgba(0, 0, 0, 0.18);
white-space: pre-line;
transform: rotate(-0.6deg);
}
.tooltip::before {
content: '';
position: absolute;
z-index: 1;
bottom: 10px;
width: 10px;
height: 10px;
background: rgba(255, 255, 255, 0.97);
border: 2px solid rgba(20, 20, 20, 0.70);
transform: rotate(45deg);
}
.tooltip--right::before {
left: -5px;
border-right: 0;
border-top: 0;
}
.tooltip--left::before {
right: -5px;
border-left: 0;
border-bottom: 0;
}
</style>

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,14 @@
import { EasyAudio } from "./utils";
import from "./电源按钮.mp3";
import from './发生器按钮.mp3'
import from './换挡.mp3'
import 线 from './线缆插入.mp3'
const ea = new EasyAudio([
{ name: "电源按钮", audioUrl: 电源按钮, volume: 1 },
{ name: "发生器按钮", audioUrl: 发生器按钮, volume: 1 },
{ name: "换挡", audioUrl: 换挡, volume: 1 },
{ name: "线缆插入", audioUrl: 线缆插入, volume: 1 },
]);
export default ea;

114
src/assets/sounds/utils.ts Normal file
View File

@ -0,0 +1,114 @@
export class EasyAudio {
private static audioCtx: AudioContext | null = null;
private buffers: Map<string, AudioBuffer> = new Map();
private sources: Map<string, AudioBufferSourceNode> = new Map();
private audioOptions: Map<
string,
{ audioUrl: string; loop?: boolean; volume?: number }
> = new Map();
private get audioCtx(): AudioContext {
if (!EasyAudio.audioCtx) {
EasyAudio.audioCtx = new AudioContext();
}
return EasyAudio.audioCtx;
}
constructor(
audios?:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
) {
if (audios) {
this.add(audios);
}
}
private async createAudioBuffer(audioUrl: string): Promise<AudioBuffer> {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
return await this.audioCtx.decodeAudioData(arrayBuffer);
}
private async loadAudio(name: string) {
const options = this.audioOptions.get(name);
if (!options) {
throw new Error(`音频 ${name} 未找到`);
}
const buffer = await this.createAudioBuffer(options.audioUrl);
this.buffers.set(name, buffer);
}
async load(): Promise<void> {
const promises = Array.from(this.audioOptions.keys()).map(name =>
this.loadAudio(name)
);
await Promise.all(promises);
}
add(
audios:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
): void {
if (Array.isArray(audios)) {
audios.forEach(audio => {
this.audioOptions.set(audio.name, {
audioUrl: typeof (audio.audioUrl) === "string" ? audio.audioUrl : audio.audioUrl.href,
loop: audio.loop,
volume: audio.volume,
});
});
} else {
this.audioOptions.set(audios.name, {
audioUrl: typeof (audios.audioUrl) === "string" ? audios.audioUrl : audios.audioUrl.href,
loop: audios.loop,
volume: audios.volume,
});
}
}
async play(name: string) {
if (!this.buffers.has(name)) {
await this.loadAudio(name);
}
const buffer = this.buffers.get(name);
const options = this.audioOptions.get(name);
if (!buffer || !options) {
throw new Error(`音频 ${name} 未找到`);
}
const source = this.audioCtx.createBufferSource();
source.buffer = buffer;
source.loop = options.loop || false;
let gainNode: GainNode | null = null;
if (options.volume !== undefined) {
gainNode = this.audioCtx.createGain();
gainNode.gain.value = options.volume;
source.connect(gainNode);
gainNode.connect(this.audioCtx.destination);
} else {
source.connect(this.audioCtx.destination);
}
source.start();
this.sources.set(name, source);
console.log(`音频 ${name} 播放`);
}
stop(name: string) {
const source = this.sources.get(name);
if (source) {
source.stop();
this.sources.delete(name);
}
}
stopAll() {
this.sources.forEach(source => source.stop());
this.sources.clear();
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,8 @@
@font-face {
font-family: 'AF-LED7 Seg-3';
src: url('AF-LED7Seg-3.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

BIN
src/assets/仪器.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
src/assets/仪器.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
src/assets/仪器臂.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
src/assets/仪器臂.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src/assets/信号发生.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
src/assets/入门引导.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
src/assets/按钮按下.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src/assets/桌面.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
src/assets/桌面.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
src/assets/示波器.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

BIN
src/assets/示波器.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1,121 @@
<template>
<div class="dm">
<MetalRuler :model-value="totalValue" class="ruler" />
<img draggable="false" src="../assets/仪器.webp" alt="">
<img draggable="false" src="../assets/仪器臂.webp" alt="" class="handle"
:style="{ '--p': totalValue / 300 }">
<SilverKnob :initial-value="totalValue * 100" :sensitivity="0.5" data-tooltip="距离调节旋钮改变两换能器间距mm用于测相位差"
@rotate="handleRotation" class="knob" />
<!-- 左侧输入 -->
<div ref="pinLeftEl" class="pin" data-tooltip="左端口应连接信号发生器发射端-换能器\n提示先点一个端口再点另一个端口完成连线" style="left: 9.5%; top: 8.7%;"
@click.stop="handlePinClick('dm:left')"></div>
<!-- 右侧输出 -->
<div ref="pinRightEl" class="pin" data-tooltip="右端口应连接信号发生器接收端-换能器\n提示先点一个端口再点另一个端口完成连线" style=" top: 8.7%;"
:style="{ left: (0.244 + totalValue / 300*.54)*100 + '%' }"
@click.stop="handlePinClick('dm:right')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin {
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.dm {
height: 300px;
width: 800px;
position: relative;
margin-top: 40px;
}
img {
height: 100%;
}
.handle {
position: absolute;
inset: 0;
left: calc(19% + var(--p) * 54%);
}
.ruler {
position: absolute;
inset: 0;
width: 100%;
top: -70px;
transform: scale(0.9);
}
.knob {
position: absolute;
right: -130px;
}
</style>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
//@ts-ignore
import MetalRuler from './MetalRuler.vue';
//@ts-ignore
import SilverKnob from './SilverKnob.vue';
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
(e: "change", value: number): void; //
(e: 'register-pin', payload: { id: 'dm:left' | 'dm:right'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'dm:left' | 'dm:right'): void;
(e: 'pin-click', id: 'dm:left' | 'dm:right'): void;
}>();
const totalValue = ref(20);
const pinLeftEl = ref<HTMLElement | null>(null);
const pinRightEl = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinLeftEl.value) emit('register-pin', { id: 'dm:left', el: pinLeftEl.value });
if (pinRightEl.value) emit('register-pin', { id: 'dm:right', el: pinRightEl.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'dm:left');
emit('unregister-pin', 'dm:right');
};
const handlePinClick = (id: 'dm:left' | 'dm:right') => {
emit('pin-click', id);
};
const handleRotation = (data: any) => {
const newValue = data.total / 100;
totalValue.value = newValue;
emit("update:modelValue", newValue);
emit("change", newValue);
};
onMounted(async () => {
await nextTick();
registerPins();
});
// pin totalValue DOM
watch(totalValue, async () => {
await nextTick();
registerPins();
});
onBeforeUnmount(() => {
unregisterPins();
});
</script>

View File

@ -0,0 +1,186 @@
<template>
<div class="ruler-container" >
<div class="ruler-body" :style="{transform: showing?'rotateX(0deg)':'rotateX(60deg) translateY(36px)'}" @pointerdown.prevent="showing=!showing">
<div class="ticks-wrapper">
<div
v-for="i in (max + 1)"
:key="i"
class="tick-item"
:style="{ left: `${((i - 1) / max) * 100}%` }"
>
<div
class="line"
:class="{
'line-major': (i - 1) % 10 === 0,
'line-medium': (i - 1) % 10 !== 0 && (i - 1) % 5 === 0,
'line-minor': (i - 1) % 5 !== 0
}"
></div>
<div
v-if="(i - 1) % 10 === 0"
class="tick-label"
>
{{ i - 1 }}
<span v-if="i - 1 === max" class="unit-mm">mm</span>
</div>
</div>
<div
class="pointer-indicator"
:style="{ left: `${(clampedValue / max) * 100}%` }"
>
<div class="pointer-head"></div>
<div class="pointer-line"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const showing = ref(false);
const props = defineProps({
modelValue: { type: Number, default: 0 },
max: { type: Number, default: 300 }
});
const clampedValue = computed(() => {
return Math.min(Math.max(props.modelValue, 0), props.max);
});
</script>
<style scoped>
.ruler-container {
perspective: 800px;
width: 100%;
min-width: 800px;
box-sizing: border-box;
}
.ruler-body {
position: relative;
height: 80px;
width: 100%;
border-radius: 4px;
/* 金属质感背景 */
background: linear-gradient(
180deg,
#f2f2f2 0%,
#e0e0e0 15%,
#d1d1d1 50%,
#e0e0e0 85%,
#f2f2f2 100%
);
box-shadow:
0 2px 5px rgba(0,0,0,0.3),
inset 0 1px 2px rgba(255,255,255,0.9),
inset 0 -1px 2px rgba(0,0,0,0.1);
border: 1px solid #999;
font-family: 'Arial', sans-serif;
user-select: none;
transition: transform 0.3s ease;
}
.brand-logo {
position: absolute;
top: 25px;
left: 15px;
color: #cc3333;
font-weight: bold;
font-size: 16px;
font-family: 'Impact', sans-serif;
z-index: 1;
letter-spacing: 1px;
}
/* 刻度容器:核心定位层 */
.ticks-wrapper {
position: relative;
/* 这里留出的边距是整个尺子的“安全区” */
width: calc(100% - 60px);
height: 100%;
margin: 0 auto; /* 居中左右各留30px */
}
.tick-item {
position: absolute;
bottom: 0;
height: 100%;
width: 0; /* 设为0完全依赖 visible 的边框或子元素,避免自身宽度影响定位 */
display: flex;
flex-direction: column;
align-items: center;
z-index: 2; /* 刻度在下层 */
}
.line {
margin-top: 15px;
width: 1px; /* 默认线宽 */
background-color: #333;
}
.line-major { height: 22px; background-color: #000; width: 2px; }
.line-medium { height: 14px; background-color: #333; }
.line-minor { height: 8px; background-color: #666; }
/* 文本标签 */
.tick-label {
position: absolute;
top: 42px;
font-size: 12px;
color: #333;
font-weight: 600;
white-space: nowrap;
}
/* 修复单位问题:绝对定位到数字右侧 */
.unit-mm {
position: absolute;
left: 100%; /* 紧贴数字右侧 */
bottom: 0; /* 底部对齐 */
font-size: 10px;
margin-left: 2px;
color: #555;
font-weight: normal;
}
/* --- 指针样式 --- */
.pointer-indicator {
position: absolute;
top: 0;
height: 100%;
width: 0; /* 自身宽度为0完全靠left定位中心 */
z-index: 10; /* 浮在刻度之上 */
pointer-events: none;
}
.pointer-line {
position: absolute;
top: 15px; /* 这里不需要顶满,根据图示,指针是在刻度区域 */
height: 35px; /* 覆盖刻度线长度 */
width: 2px;
background-color: rgba(220, 0, 0, 0.9);
/* 居中核心:向左移自身宽度的一半 */
transform: translateX(-50%);
box-shadow: 0 0 2px rgba(255, 0, 0, 0.5);
}
.pointer-head {
position: absolute;
top: 8px;
left: 0;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #cc0000; /* 倒三角 */
/* 或者做成图中的游标滑块样式 */
}
</style>

742
src/components/Notebook.vue Normal file
View File

@ -0,0 +1,742 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue';
type WiringStatus = {
txWave_to_ch1: boolean;
txTransducer_to_left: boolean;
rxWave_to_ch2: boolean;
rxTransducer_to_right: boolean;
};
type OscStatus = {
vdch1: number;
vdch2: number;
currentModeIndex: number;
xyMode: boolean;
power: boolean;
};
const props = defineProps<{
wiring: WiringStatus;
osc: OscStatus;
signalFrequencyKHz: number;
lissajousLineStreak: number;
}>();
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
const stage1Done = computed(() => {
const w = props.wiring;
return w.txWave_to_ch1 && w.txTransducer_to_left && w.rxWave_to_ch2 && w.rxTransducer_to_right;
});
const vdch1Ok = computed(() => inRange(props.osc.vdch1, 140, 220));
const vdch2Ok = computed(() => inRange(props.osc.vdch2, 140, 220));
const modeOk = computed(() => props.osc.currentModeIndex === 2);
const powerOk = computed(() => props.osc.power === true);
const stage2Done = computed(() => powerOk.value && vdch1Ok.value && vdch2Ok.value && modeOk.value);
const stage3Done = computed(() => inRange(props.signalFrequencyKHz, 35.98, 36.02));
const xyOk = computed(() => props.osc.xyMode === true);
const lineCount = computed(() => Math.max(0, Math.min(4, Math.trunc(props.lissajousLineStreak))));
const stage4Done = computed(() => xyOk.value && lineCount.value >= 4);
const allDone = computed(() => stage1Done.value && stage2Done.value && stage3Done.value && stage4Done.value);
const fmtKHz = (v: number) => (Number.isFinite(v) ? v.toFixed(3) : '--');
// ===== =====
const currentPage = ref<1 | 2 | 3>(1);
const goPage = (p: 1 | 2 | 3) => {
currentPage.value = p;
};
// ===== =====
type RawRow = { distanceMm: string; note: string };
type RawData = {
// 线mm
rows: RawRow[];
// kHz
naturalFreqKHz: string;
};
const RAW_STORAGE_KEY = 'sound-speed-notebook:raw-data';
const rawData = ref<RawData>({
rows: Array.from({ length: 4 }, () => ({ distanceMm: '', note: '' })),
naturalFreqKHz: '',
});
const isValidNumber = (s: string) => {
const v = Number(s);
return s.trim() !== '' && Number.isFinite(v);
};
const rawComplete = computed(() => {
const freqOk = isValidNumber(rawData.value.naturalFreqKHz);
const distances = rawData.value.rows.map((r) => Number(r.distanceMm)).filter((v) => Number.isFinite(v));
return freqOk && distances.length >= 2;
});
const addRawRow = () => {
rawData.value.rows.push({ distanceMm: '', note: '' });
};
const removeRawRow = () => {
if (rawData.value.rows.length <= 1) return;
rawData.value.rows.pop();
};
const parsedDistances = computed(() => {
// /
return rawData.value.rows
.map((r) => Number(r.distanceMm))
.filter((v) => Number.isFinite(v));
});
const deltasMm = computed(() => {
const ds = parsedDistances.value;
const deltas: number[] = [];
for (let i = 1; i < ds.length; i++) {
const prev = ds[i - 1];
const cur = ds[i];
if (prev === undefined || cur === undefined) continue;
deltas.push(Math.abs(cur - prev));
}
return deltas;
});
const avgDeltaMm = computed(() => {
const deltas = deltasMm.value;
if (deltas.length === 0) return null;
const sum = deltas.reduce((a, b) => a + b, 0);
return sum / deltas.length;
});
const naturalFreqKHz = computed(() => {
const v = Number(rawData.value.naturalFreqKHz);
// /退
if (!Number.isFinite(v)) return null;
return v;
});
// 线 nπ线 Δl = λ/2 => v = fλ = 2 f Δl
const soundSpeedMs = computed(() => {
const f = naturalFreqKHz.value;
const dl = avgDeltaMm.value;
if (f === null || dl === null) return null;
// f(kHz) * dl(mm) => m/s 1000 Hz * 1e-3 m
return 2 * f * dl;
});
// ===== =====
type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
const submitState = ref<SubmitState>('idle');
const submitMessage = ref('');
const postJson = async (url: string, data: unknown) => {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
return res;
};
const postTracker = (event: string, data?: unknown) => {
const body = JSON.stringify({ event, data, ts: Date.now() });
try {
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
const blob = new Blob([body], { type: 'application/json' });
// @ts-ignore
navigator.sendBeacon('/api/tracker', blob);
return;
}
} catch {
// ignore
}
fetch('/api/tracker', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* ignore */ });
};
const submitRecord = async () => {
if (submitState.value === 'submitting') return;
if (!rawComplete.value) {
submitState.value = 'error';
submitMessage.value = '请至少填写 2 组有效距离并填写固有频率kHz。';
postTracker('record_submit_blocked', {
reason: 'incomplete',
rows: rawData.value.rows.length,
validDistances: parsedDistances.value.length,
hasFreq: isValidNumber(rawData.value.naturalFreqKHz),
});
return;
}
submitState.value = 'submitting';
submitMessage.value = '提交中…';
const payload = {
ts: Date.now(),
raw: {
naturalFreqKHz: rawData.value.naturalFreqKHz,
rows: rawData.value.rows,
},
computed: {
avgDeltaMm: avgDeltaMm.value,
soundSpeedMs: soundSpeedMs.value,
deltasMm: deltasMm.value,
validDistancesMm: parsedDistances.value,
},
context: {
wiring: props.wiring,
osc: props.osc,
signalFrequencyKHz: props.signalFrequencyKHz,
lissajousLineStreak: props.lissajousLineStreak,
},
notes: notesText.value,
};
try {
const res = await postJson('/api/record', payload);
if (!res.ok) {
submitState.value = 'error';
submitMessage.value = `提交失败(${res.status}`;
postTracker('record_submit_failed', { status: res.status });
return;
}
submitState.value = 'success';
submitMessage.value = '提交成功';
postTracker('record_submitted', {
rows: rawData.value.rows.length,
validDistances: parsedDistances.value.length,
avgDeltaMm: avgDeltaMm.value,
soundSpeedMs: soundSpeedMs.value,
});
} catch (e: any) {
submitState.value = 'error';
submitMessage.value = '提交失败(网络错误)';
postTracker('record_submit_failed', { error: String(e?.message ?? e) });
}
};
// ===== + =====
const NOTES_STORAGE_KEY = 'sound-speed-notebook:notes';
const notesText = ref('');
onMounted(() => {
try {
const saved = window.localStorage.getItem(NOTES_STORAGE_KEY);
if (typeof saved === 'string') notesText.value = saved;
} catch {
// ignore
}
try {
const savedRaw = window.localStorage.getItem(RAW_STORAGE_KEY);
if (typeof savedRaw === 'string' && savedRaw.trim()) {
const parsed = JSON.parse(savedRaw) as Partial<RawData>;
const rows = parsed.rows;
const naturalFreqKHz = typeof (parsed as any)?.naturalFreqKHz === 'string' ? (parsed as any).naturalFreqKHz : '';
if (Array.isArray(rows) && rows.length > 0) {
rawData.value = {
rows: rows.map((r: any) => ({
distanceMm: String(r?.distanceMm ?? ''),
note: String(r?.note ?? ''),
})),
naturalFreqKHz,
};
} else {
rawData.value.naturalFreqKHz = naturalFreqKHz;
}
}
} catch {
// ignore
}
});
let saveTimer: number | undefined;
watch(notesText, () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
try {
window.localStorage.setItem(NOTES_STORAGE_KEY, notesText.value);
} catch {
// ignore
}
}, 250);
});
let saveRawTimer: number | undefined;
watch(rawData, () => {
if (saveRawTimer !== undefined) window.clearTimeout(saveRawTimer);
saveRawTimer = window.setTimeout(() => {
try {
window.localStorage.setItem(RAW_STORAGE_KEY, JSON.stringify(rawData.value));
} catch {
// ignore
}
}, 250);
}, { deep: true });
</script>
<template>
<div class="notebook">
<div class="header">
<div class="title">记事本</div>
<div class="right">
<div class="pager" role="tablist" aria-label="记事本翻页">
<button class="page-btn" :class="{ active: currentPage === 1 }" type="button" @click="goPage(1)">步骤</button>
<button class="page-btn" :class="{ active: currentPage === 2 }" type="button" @click="goPage(2)">记录</button>
<button class="page-btn" :class="{ active: currentPage === 3 }" type="button" @click="goPage(3)">提交</button>
</div>
<div class="summary" :class="{ done: allDone }">{{ allDone ? '全部完成' : '进行中' }}</div>
</div>
</div>
<div class="body">
<template v-if="currentPage === 1">
<div class="section" :class="{ done: stage1Done }">
<div class="section-title">阶段一仪器接线</div>
<label class="item"><input type="checkbox" :checked="wiring.txWave_to_ch1" disabled /> 信号发生器 发射端 波形 示波器 CH1</label>
<label class="item"><input type="checkbox" :checked="wiring.txTransducer_to_left" disabled /> 信号发生器 发射端 换能器 换能器 左侧输入</label>
<label class="item"><input type="checkbox" :checked="wiring.rxWave_to_ch2" disabled /> 信号发生器 接收端 波形 示波器 CH2</label>
<label class="item"><input type="checkbox" :checked="wiring.rxTransducer_to_right" disabled /> 信号发生器 接收端 换能器 换能器 右侧输出</label>
<div class="hint" :class="{ ok: stage1Done }">{{ stage1Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage2Done }">
<div class="section-title">阶段二调整示波器设置</div>
<div class="item text">示波器开机 <span :class="{ ok: powerOk }">{{ powerOk ? '✓' : '✗' }}</span></div>
<div class="item text">调整 VOLTS/DIV X<span :class="{ ok: vdch1Ok }">{{ vdch1Ok ? '✓' : '✗' }}</span></div>
<div class="item text">调整 VOLTS/DIV Y<span :class="{ ok: vdch2Ok }">{{ vdch2Ok ? '✓' : '✗' }}</span></div>
<div class="item text">模式切换至 双踪 <span :class="{ ok: modeOk }">{{ modeOk ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage2Done }">{{ stage2Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage3Done }">
<div class="section-title">阶段三找到固有频率</div>
<div class="item text">信号频率35.98036.020 kHz当前 {{ fmtKHz(signalFrequencyKHz) }} <span :class="{ ok: stage3Done }">{{ stage3Done ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage3Done }">{{ stage3Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage4Done }">
<div class="section-title">阶段四相位比较法测声速</div>
<div class="item text">1) 切换 X-Y 模式 <span :class="{ ok: xyOk }">{{ xyOk ? '✓' : '✗' }}</span></div>
<div class="item text">2) 连续 4 次李萨如图形出现直线{{ lineCount }}/4 <span :class="{ ok: lineCount >= 4 }">{{ lineCount >= 4 ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage4Done }">{{ stage4Done ? '完成' : '未完成' }}</div>
</div>
<div class="notes">
<div class="section-title">注意事项</div>
<div class="note">- 点击一个端口再点击另一个端口以连线</div>
<div class="note">- 若接线不符合示意会弹窗提示</div>
<div class="note">- 阶段四需要在 X-Y 模式下通过移动换能器距离多次让图形变直线</div>
</div>
</template>
<template v-else-if="currentPage === 2">
<div class="notes" style="margin-top: 0; padding-top: 0; border-top: none;">
<div class="section-title">学生笔记</div>
<div class="note">- 可记录观察数据计算过程会自动保存</div>
<textarea
v-model="notesText"
class="notes-input"
rows="10"
placeholder="在这里记录你的观察、数据、计算过程……(会自动保存)"
/>
</div>
</template>
<template v-else>
<div class="notes" style="margin-top: 0; padding-top: 0; border-top: none;">
<div class="section-title">答题填写实验数据</div>
<div class="note">- 请输入出现直线时的换能器距离mm序号可自由扩展</div>
<div class="note">- 并填写你找到的固有频率kHz系统将自动计算平均 Δl 与声速</div>
<div class="raw-status" :class="{ ok: rawComplete }">{{ rawComplete ? '原始数据:已填写(可计算)' : '原始数据:请至少填 2 组距离 + 固有频率' }}</div>
<div class="raw-controls">
<button class="raw-btn" type="button" @click="addRawRow">+ 增加一行</button>
<button class="raw-btn" type="button" @click="removeRawRow">- 删除末行</button>
</div>
<div class="raw-freq">
<div class="raw-head">固有频率 (kHz)</div>
<input v-model="rawData.naturalFreqKHz" class="raw-input" inputmode="decimal" placeholder="例如 36.000" />
</div>
<div class="raw-table">
<div class="raw-head">序号</div>
<div class="raw-head">距离 (mm)</div>
<div class="raw-head">备注</div>
<template v-for="(r, idx) in rawData.rows" :key="idx">
<div class="raw-cell">{{ idx + 1 }}</div>
<div class="raw-cell">
<input
v-model="r.distanceMm"
class="raw-input"
inputmode="decimal"
placeholder="例如 120"
/>
</div>
<div class="raw-cell">
<input v-model="r.note" class="raw-input" placeholder="可选" />
</div>
</template>
</div>
<div class="calc">
<div class="calc-row">有效距离数{{ parsedDistances.length }}Δl 组数{{ deltasMm.length }}</div>
<div class="calc-row">平均 Δl{{ avgDeltaMm === null ? '--' : avgDeltaMm.toFixed(3) }} mm</div>
<div class="calc-row">声速{{ soundSpeedMs === null ? '--' : soundSpeedMs.toFixed(2) }} m/s</div>
<div class="calc-row hint">公式连续直线 Δl = λ/2 $v=f\lambda=2f\Delta l$</div>
</div>
<div class="submit-row">
<button class="submit-btn" type="button" :disabled="submitState === 'submitting'" @click="submitRecord">
{{ submitState === 'submitting' ? '提交中…' : '提交' }}
</button>
<div class="submit-msg" :class="{ ok: submitState === 'success', err: submitState === 'error' }">
{{ submitMessage }}
</div>
</div>
<div class="note" style="margin-top: 8px;">
- 当前直线计数{{ lineCount }}/4用于提醒不影响填写
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.notebook {
width: 340px;
height: 420px;
background:
linear-gradient(rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.55)),
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.00) 0px,
rgba(0, 0, 0, 0.00) 20px,
rgba(0, 0, 0, 0.04) 21px
),
rgba(245, 245, 245, 0.92);
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
padding: 12px;
box-sizing: border-box;
color: rgba(20, 20, 20, 0.95);
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex: 0 0 auto;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.pager {
display: inline-flex;
background: rgba(0, 0, 0, 0.06);
border-radius: 999px;
padding: 2px;
gap: 2px;
}
.page-btn {
appearance: none;
border: none;
background: transparent;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: rgba(20, 20, 20, 0.85);
cursor: pointer;
}
.page-btn.active {
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
color: rgba(20, 20, 20, 0.95);
}
.body {
flex: 1 1 auto;
overflow: auto;
padding-right: 6px;
}
/* 美观滚动条Chromium/WebKit */
.body::-webkit-scrollbar {
width: 10px;
}
.body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.06);
border-radius: 999px;
}
.body::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.22);
border-radius: 999px;
border: 2px solid rgba(245, 245, 245, 0.92);
}
.body::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.32);
}
/* Firefox */
.body {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.28) rgba(0, 0, 0, 0.06);
}
.title {
font-weight: 700;
letter-spacing: 1px;
}
.summary {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
}
.summary.done {
background: rgba(0, 0, 0, 0.12);
}
.section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.18);
}
.section.done {
background: rgba(34, 197, 94, 0.12);
border-radius: 8px;
padding: 10px 10px 8px;
border-top: 1px dashed rgba(34, 197, 94, 0.25);
}
.section-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 6px;
}
.item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 1.25;
margin: 4px 0;
}
.item.text {
justify-content: space-between;
}
.item input {
width: 14px;
height: 14px;
}
.hint {
margin-top: 4px;
font-size: 12px;
opacity: 0.8;
}
.ok {
font-weight: 700;
}
.notes {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.18);
}
.note {
font-size: 12px;
margin: 4px 0;
opacity: 0.9;
}
.notes-input {
width: 100%;
resize: vertical;
min-height: 120px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.22);
background: rgba(255, 255, 255, 0.75);
padding: 8px;
box-sizing: border-box;
font-size: 12px;
line-height: 1.35;
color: rgba(20, 20, 20, 0.95);
outline: none;
}
.notes-input:focus {
border-color: rgba(0, 0, 0, 0.35);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.10);
}
.raw-status {
margin-top: 8px;
font-size: 12px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.06);
}
.raw-status.ok {
background: rgba(34, 197, 94, 0.14);
}
.raw-controls {
margin-top: 8px;
display: flex;
gap: 8px;
}
.raw-btn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.18);
background: rgba(255, 255, 255, 0.72);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.raw-btn:hover {
background: rgba(255, 255, 255, 0.90);
}
.raw-freq {
margin-top: 10px;
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
align-items: center;
}
.raw-table {
margin-top: 8px;
display: grid;
grid-template-columns: 50px 110px 1fr;
gap: 6px;
align-items: center;
}
.raw-head {
font-size: 12px;
font-weight: 700;
opacity: 0.85;
}
.raw-cell {
font-size: 12px;
}
.raw-input {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.20);
background: rgba(255, 255, 255, 0.75);
padding: 6px 8px;
box-sizing: border-box;
font-size: 12px;
outline: none;
}
.raw-input:focus {
border-color: rgba(0, 0, 0, 0.35);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.10);
}
.calc {
margin-top: 10px;
padding: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.05);
}
.calc-row {
font-size: 12px;
margin: 4px 0;
}
.calc-row.hint {
opacity: 0.75;
}
.submit-row {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.submit-btn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.18);
background: rgba(255, 255, 255, 0.85);
border-radius: 10px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.submit-msg {
flex: 1 1 auto;
font-size: 12px;
opacity: 0.85;
}
.submit-msg.ok {
color: rgba(18, 120, 60, 0.95);
opacity: 1;
font-weight: 700;
}
.submit-msg.err {
color: rgba(180, 30, 30, 0.95);
opacity: 1;
font-weight: 700;
}
</style>

View File

@ -0,0 +1,414 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch, onUnmounted, nextTick } from 'vue';
import RetroKnob from './RetroKnob.vue';
//@ts-ignore
import SwitchLever from './SwitchLever.vue';
import ea from '../assets/sounds';
// 40 -> 320 bigger is smaller volts, 40 means 5V/div, 320 means 1mV/div, not linear
const vdch1 = ref(320)
const vdch2 = ref(320)
const xymode = ref(false)
const power = ref(false)
const currentModeIndex = ref(0);
const labels = ['CH1', 'CH2', '双踪', '叠加'];
const modeText = computed(() => labels[currentModeIndex.value]);
const handleModeChange = (val: string) => {
console.log('模式切换为:', val);
ea.play('换挡')
};
const props = defineProps({
// mm
distance: { type: Number, default: 0 },
// Hz
frequency: { type: Number, default: 36000 },
// m/s
soundSpeed: { type: Number, default: 340 },
});
const emit = defineEmits<{
(e: 'register-pin', payload: { id: 'osc:ch1' | 'osc:ch2'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'pin-click', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'settings', payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }): void;
(e: 'lissajous-line', payload: { k: number; phaseRadTotal: number }): void;
}>();
const emitSettings = () => {
emit('settings', {
vdch1: vdch1.value,
vdch2: vdch2.value,
currentModeIndex: currentModeIndex.value,
xyMode: xymode.value,
power: power.value,
});
};
watch([vdch1, vdch2, currentModeIndex, xymode, power], () => {
emitSettings();
}, { immediate: true });
const lastWasLine = ref(false);
const lastLineK = ref<number | null>(null);
const pinCh1El = ref<HTMLElement | null>(null);
const pinCh2El = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinCh1El.value) emit('register-pin', { id: 'osc:ch1', el: pinCh1El.value });
if (pinCh2El.value) emit('register-pin', { id: 'osc:ch2', el: pinCh2El.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'osc:ch1');
emit('unregister-pin', 'osc:ch2');
};
const handlePinClick = (id: 'osc:ch1' | 'osc:ch2') => {
emit('pin-click', id);
};
// Canvas
const canvasRef = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number | null = null;
let time = 0;
//
const NATURAL_FREQ = 36000; // Hzprops.frequency
const Q_FACTOR = 100; //
//
function calculatePhysics() {
// mmcmm
const distanceM = props.distance / 1000; // mm -> m
// (m)
const wavelengthM = props.soundSpeed / props.frequency;
//
const numCycles = distanceM / wavelengthM;
const phaseRadTotal = numCycles * 2 * Math.PI;
const phaseRadEffective = phaseRadTotal % (2 * Math.PI);
//
const f_ratio = props.frequency / NATURAL_FREQ;
const f_term = (f_ratio - (1 / f_ratio));
let resonanceFactor = 1 / Math.sqrt(1 + (Q_FACTOR * Q_FACTOR * f_term * f_term));
if (isNaN(resonanceFactor)) resonanceFactor = 0;
return {
wavelengthM,
phaseRadTotal,
phaseRadEffective,
resonanceFactor
};
}
//
const isXYMode = computed(() => xymode.value);
const isDualTrace = computed(() => currentModeIndex.value === 2); //
// 40=320=线
const getGain = (knobValue: number) => {
// 40-3200.01-5.0线
const normalized = (knobValue - 40) / 280; // 0-1
return 0.01 + normalized * normalized * 4.99; // 使
};
//
function drawOscilloscope() {
if (!ctx || !canvasRef.value) {
return;
}
//
const w = canvasRef.value.width;
const h = canvasRef.value.height;
ctx.clearRect(0, 0, w, h);
//
if (!power.value) {
return;
}
const cx = w / 2;
const cy = h / 2;
const scale = Math.min(w, h) * 0.35;
const phy = calculatePhysics();
const gainCH1 = getGain(vdch1.value);
const gainCH2 = getGain(vdch2.value);
// X-Y 线
if (power.value && isXYMode.value) {
const phase = phy.phaseRadTotal;
const isLineNow = Math.abs(Math.sin(phase)) < 0.10 && phy.resonanceFactor > 0.05;
if (isLineNow && !lastWasLine.value) {
const k = Math.round(phase / Math.PI);
// k
if (lastLineK.value !== k) {
lastLineK.value = k;
emit('lissajous-line', { k, phaseRadTotal: phase });
}
}
lastWasLine.value = isLineNow;
} else {
lastWasLine.value = false;
}
//
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isXYMode.value) {
// ================= X-Y Mode () =================
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
// -
ctx.shadowBlur = 15 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
ctx.beginPath();
const phase = phy.phaseRadTotal;
const ampX = gainCH1;
const ampY = phy.resonanceFactor * gainCH2;
for (let t = 0; t <= 2 * Math.PI; t += 0.03) {
const x = scale * ampX * Math.cos(t);
const y = scale * ampY * Math.cos(t - phase);
if (t === 0) ctx.moveTo(cx + x, cy - y);
else ctx.lineTo(cx + x, cy - y);
}
ctx.stroke();
//
ctx.shadowBlur = 25 * phy.resonanceFactor;
ctx.stroke();
ctx.shadowBlur = 0;
//
ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
ctx.fillRect(cx - 1, cy - 5, 2, 10);
ctx.fillRect(cx - 5, cy - 1, 10, 2);
} else {
// ================= Y-t Mode (CH1/CH2//) =================
//
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
ctx.stroke();
const cyclesToShow = 2.5; // 2.5
const pixPerCycle = w / cyclesToShow;
ctx.lineWidth = 3;
// CH1 (Source) - CH1
if (currentModeIndex.value === 0 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 230, 0, 0.9)';
ctx.shadowBlur = 12;
ctx.shadowColor = '#ffff00';
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * gainCH1 * 0.8 * Math.sin(t + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
ctx.stroke();
//
ctx.shadowBlur = 20;
ctx.stroke();
}
// CH2 (Receiver) - 绿CH2
if (currentModeIndex.value === 1 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
ctx.beginPath();
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
ctx.shadowBlur = 12 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
const phaseShift = phy.phaseRadTotal;
const ampY = phy.resonanceFactor * gainCH2 * 0.8;
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * ampY * Math.sin(t - phaseShift + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
ctx.stroke();
//
ctx.shadowBlur = 20 * phy.resonanceFactor;
ctx.stroke();
}
ctx.shadowBlur = 0;
}
}
//
function animate() {
time += 0.05;
drawOscilloscope();
animationId = requestAnimationFrame(animate);
}
// Canvas
function initCanvas() {
const canvas = canvasRef.value;
if (!canvas) return;
ctx = canvas.getContext('2d');
if (!ctx) return;
// Canvas
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = 1072;
canvas.height = 812;
//
animate();
}
//
watch([() => props.distance, () => props.frequency, () => props.soundSpeed,
isXYMode, currentModeIndex, power], () => {
//
});
onMounted(() => {
initCanvas();
nextTick().then(registerPins);
});
onUnmounted(() => {
if (animationId !== null) {
cancelAnimationFrame(animationId);
}
unregisterPins();
});
</script>
<template>
<div class="osc">
<img src="../assets/示波器.webp" alt="" draggable="false">
<RetroKnob v-model="vdch1" class="btn" data-tooltip="CH1 垂直灵敏度Volts/Div调到合适幅度便于观察"
style="left: 34.3%; top: 28.4%;" />
<RetroKnob v-model="vdch2" class="btn" data-tooltip="CH2 垂直灵敏度Volts/Div调到合适幅度便于观察"
style="left: 47.3%; top: 28.4%;" />
<div class="powerl" draggable="false" :style="{ opacity: power ? 0.8 : 0 }"></div>
<img class="power-btn" data-tooltip="电源开关开机后才会显示波形/李萨如图形" @click="power = !power; ea.play('电源按钮')"
:style="{ opacity: power ? '80%' : '0' }" src="../assets/按钮按下.webp">
<!-- <div>{{ vdch1 }} {{ vdch2 }}</div> -->
<SwitchLever v-model="currentModeIndex" data-tooltip="显示模式CH1 / CH2 / 双踪 / 叠加(步骤二需要切到“双踪”)" @change="handleModeChange"
scale="0.20" class="mode-swtc" />
<div class="xymode" data-tooltip="X-Y李萨如模式用于相位比较法步骤四" @click="xymode = !xymode; ea.play('电源按钮')"></div>
<canvas ref="canvasRef" class="screen" width="1072"
height="812"></canvas>
<!-- CH1 输入 -->
<div ref="pinCh1El" class="pin" data-tooltip="CH1 输入端口建议连接信号发生器发射端-波形\n提示先点一个端口再点另一个端口完成连线" style="left: 49.8%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch1')"></div>
<!-- CH2 输入 -->
<div ref="pinCh2El" class="pin" data-tooltip="CH2 输入端口建议连接信号发生器接收端-波形\n提示先点一个端口再点另一个端口完成连线" style="left: 62.7%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch2')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin {
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.osc {
position: relative;
height: 300px;
width: fit-content;
}
.screen {
position: absolute;
left: 7.5%;
top: 31.2%;
width: 32.2%;
background-image:
linear-gradient(rgba(0, 255, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 0, 0.1) 1px, transparent 1px);
background-size: calc(1072px / 10.72) calc(812px / 8.12);
border-radius: 4px;
}
.xymode {
position: absolute;
left: 80.2%;
top: 44.4%;
width: 20px;
height: 17px;
cursor: pointer;
}
img {
height: 100%;
}
.mode-swtc {
position: absolute;
left: 57.8%;
top: 42.4%;
}
.btn {
position: absolute;
transform: scale(0.164);
inset: 0;
}
.powerl {
background-color: orangered;
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
left: 36.55%;
top: 93.8%;
transform: translate(-50%, -50%);
box-shadow: 0 0 8px 2px orangered;
transition: all 0.3s ease;
opacity: .8;
}
.power-btn {
position: absolute;
height: 29px;
left: 42.32%;
top: 90.7%;
transform: translate(-50%, -50%);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,386 @@
<template>
<div class="knob-container" ref="containerRef">
<div class="side-layer">
<div class="side-texture" :style="{ transform: `rotate(${angle}deg)` }"></div>
<div class="side-shade"></div>
</div>
<div class="top-layer">
<div class="top-rotator" :style="{ transform: `rotate(${angle}deg)` }">
<div class="top-bevel"></div>
<div class="inner-cap"></div>
<div class="pointer"></div>
</div>
</div>
<div
class="interaction-mask"
ref="knobAreaRef"
:class="{ dragging: isDragging, disabled }"
@pointerdown="onPointerDown"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
type Props = {
modelValue?: number;
disabled?: boolean;
/** 角度限制范围deg */
minAngle?: number;
maxAngle?: number;
/** 步进deg<=0 表示不启用 */
step?: number;
};
const props = withDefaults(defineProps<Props>(), {
modelValue: 0,
disabled: false,
minAngle: 40,
maxAngle: 320,
step: 0,
});
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
(e: "change", value: number): void; //
}>();
const containerRef = ref<HTMLElement | null>(null);
const knobAreaRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const range = computed(() => {
//
const a = Number(props.minAngle);
const b = Number(props.maxAngle);
return a <= b ? { min: a, max: b } : { min: b, max: a };
});
function clamp(v: number) {
return Math.min(range.value.max, Math.max(range.value.min, v));
}
function applyStep(v: number) {
const s = Number(props.step);
if (!Number.isFinite(s) || s <= 0) return v;
// min
const q = Math.round((v - range.value.min) / s) * s + range.value.min;
return q;
}
/** 把 rawAngle 变成与 prevAngle 最接近的等价角±360k避免跨越 -180/180 时跳变 */
function unwrapAngle(rawAngle: number, prevAngle: number) {
let a = rawAngle;
let best = a;
let bestDiff = Math.abs(a - prevAngle);
const candidates = [a - 360, a, a + 360];
for (const c of candidates) {
const d = Math.abs(c - prevAngle);
if (d < bestDiff) {
bestDiff = d;
best = c;
}
}
return best;
}
const angle = computed(() => {
const v = Number(props.modelValue ?? 0);
const safe = Number.isFinite(v) ? v : 0;
// modelValue step
return clamp(applyStep(clamp(safe)));
});
function getCenter(el: HTMLElement) {
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function calculateAngleFromClientPoint(clientX: number, clientY: number) {
const el = knobAreaRef.value;
if (!el) return angle.value;
const center = getCenter(el);
const dx = clientX - center.x;
const dy = clientY - center.y;
// atan2 -> deg -> -90 0°
const raw = Math.atan2(dy, dx) * (180 / Math.PI) - 90;
return raw;
}
function emitAngle(next: number) {
const stepped = applyStep(next);
const clamped = clamp(stepped);
emit("update:modelValue", clamped);
}
let activePointerId: number | null = null;
// clamp unwrap
let lastContinuousAngle = angle.value;
function onPointerDown(e: PointerEvent) {
if (props.disabled) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const el = knobAreaRef.value;
if (!el) return;
isDragging.value = true;
activePointerId = e.pointerId;
try {
el.setPointerCapture(e.pointerId);
} catch {}
e.preventDefault();
const raw = calculateAngleFromClientPoint(e.clientX, e.clientY);
//
lastContinuousAngle = unwrapAngle(raw, angle.value);
emitAngle(lastContinuousAngle);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
e.preventDefault();
const raw = calculateAngleFromClientPoint(e.clientX, e.clientY);
const continuous = unwrapAngle(raw, lastContinuousAngle);
lastContinuousAngle = continuous;
emitAngle(continuous);
}
function endDrag(e?: PointerEvent) {
if (!isDragging.value) return;
isDragging.value = false;
activePointerId = null;
emit("change", angle.value);
if (e) {
const el = knobAreaRef.value;
if (el) {
try {
el.releasePointerCapture(e.pointerId);
} catch {}
}
}
}
function onPointerUp(e: PointerEvent) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
endDrag(e);
}
function onPointerCancel(e: PointerEvent) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
endDrag(e);
}
window.addEventListener("pointermove", onPointerMove, { passive: false });
window.addEventListener("pointerup", onPointerUp, { passive: false });
window.addEventListener("pointercancel", onPointerCancel, { passive: false });
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onPointerMove as any);
window.removeEventListener("pointerup", onPointerUp as any);
window.removeEventListener("pointercancel", onPointerCancel as any);
});
// /step
watch(
() => [props.minAngle, props.maxAngle, props.step],
() => {
emitAngle(Number(props.modelValue ?? 0));
}
);
</script>
<style scoped>
/* 组件本身不管页面背景;外层自己设置 */
.knob-container {
position: relative;
width: 200px;
height: 200px;
border-radius: 50%;
user-select: none;
-webkit-user-select: none;
touch-action: none;
filter: drop-shadow(0 30px 45px rgba(0, 0, 0, 0.1));
}
/* 外圈:更强的俯视感(高光集中在上方,底部更暗) */
.knob-container::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
background: radial-gradient(
120% 90% at 40% 20%,
rgba(255, 255, 255, 0.35),
rgba(255, 255, 255, 0.05) 45%,
rgba(0, 0, 0, 0.35) 100%
),
radial-gradient(
60% 60% at 50% 50%,
rgba(255, 255, 255, 0.08),
rgba(0, 0, 0, 0.45)
);
box-shadow: inset 0 10px 22px rgba(255, 255, 255, 0.18),
inset 0 -14px 26px rgba(0, 0, 0, 0.55);
z-index: 0;
}
/* === 侧面层:露出更多(通过 top-layer 下移 + 缩小实现) === */
.side-layer {
position: absolute;
inset: 0;
border-radius: 50%;
overflow: hidden;
z-index: 1;
background: #dcdabb;
box-shadow: inset 0 18px 30px -8px rgba(255, 253, 240, 0.85),
inset 0 -10px 22px rgba(0, 0, 0, 0.55);
}
.side-texture {
position: absolute;
top: -55%;
left: -55%;
width: 210%;
height: 210%;
background: repeating-conic-gradient(
from 0deg,
#f2f0e0 0deg 2deg,
#c4c2a8 2deg 4deg
);
z-index: 1;
}
/* 给侧面加一点“俯视遮光”(上亮下暗) */
.side-shade {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
120% 90% at 45% 18%,
rgba(255, 255, 255, 0.20),
rgba(255, 255, 255, 0.02) 45%,
rgba(0, 0, 0, 0.25) 100%
);
z-index: 2;
}
/* === 顶面层:下移更多、尺寸更小 => 上侧面露出更大 === */
.top-layer {
position: absolute;
width: 178px;
height: 178px;
top: 30px; /* 关键:更明显露出上侧面 */
left: 11px;
border-radius: 50%;
overflow: hidden;
z-index: 3;
background: radial-gradient(
130% 110% at 40% 22%,
#fffef5,
#f1f0e3 55%,
#dbd9c5 100%
),
linear-gradient(to bottom, rgba(255, 255, 255, 0.35), rgba(0, 0, 0, 0.15));
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.40),
inset 0 10px 18px rgba(255, 255, 255, 0.55),
inset 0 -16px 24px rgba(0, 0, 0, 0.30);
}
.top-rotator {
position: relative;
width: 100%;
height: 100%;
}
/* 顶面倒角环,增强“俯视立体” */
.top-bevel {
position: absolute;
inset: 8px;
border-radius: 50%;
box-shadow: inset 0 6px 10px rgba(255, 255, 255, 0.55),
inset 0 -10px 16px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.inner-cap {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 66%;
height: 66%;
border-radius: 50%;
background: radial-gradient(
130% 110% at 40% 22%,
#ffffff,
#eeece0 60%,
#d9d7c3 100%
),
linear-gradient(160deg, rgba(255, 255, 255, 0.6), rgba(0, 0, 0, 0.12));
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.18),
inset 0 4px 8px rgba(255, 255, 255, 0.85),
inset 0 -10px 14px rgba(0, 0, 0, 0.18);
}
.pointer {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 26px;
background: linear-gradient(to bottom, #7a776f, #4e4b45);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(255, 255, 255, 0.55),
inset 0 1px 2px rgba(255, 255, 255, 0.25);
}
/* 交互遮罩 */
.interaction-mask {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
border-radius: 50%;
cursor: grab;
z-index: 10;
}
.interaction-mask.dragging {
cursor: grabbing;
}
.interaction-mask.disabled {
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,267 @@
<template>
<div class="sigen">
<img src="../assets/信号发生.webp" draggable="false" alt="">
<span class="num"
:class="[{ 'has-dot': digits[0]!.hasDot }, { selected: selectedIndex === 0 }, { blinking: selectedIndex === 0 && isBlinking }]"
style="left: 16.5%; top: 40.4%;">{{ digits[0]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[1]!.hasDot }, { selected: selectedIndex === 1 }, { blinking: selectedIndex === 1 && isBlinking }]"
style="left: 23.8%; top: 40.4%;">{{ digits[1]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[2]!.hasDot }, { selected: selectedIndex === 2 }, { blinking: selectedIndex === 2 && isBlinking }]"
style="left: 30.9%; top: 40.4%;">{{ digits[2]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[3]!.hasDot }, { selected: selectedIndex === 3 }, { blinking: selectedIndex === 3 && isBlinking }]"
style="left: 38.1%; top: 40.4%;">{{ digits[3]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[4]!.hasDot }, { selected: selectedIndex === 4 }, { blinking: selectedIndex === 4 && isBlinking }]"
style="left: 45.1%; top: 40.4%;">{{ digits[4]!.value }}</span>
<div style="left: 77.5%; top: 36.9%;" class="btn btn-increase" data-tooltip="增大频率对当前选中的那一位生效" @click.stop="increase"></div>
<div style="left: 77.5%; top: 51.9%;" class="btn btn-decrease" data-tooltip="减小频率对当前选中的那一位生效" @click.stop="decrease"></div>
<div style="left: 70.6%; top: 44.9%;" class="btn btn-left" data-tooltip="选择更高位" @click.stop="selectLeft"></div>
<div style="left: 84.2%; top: 44.9%;" class="btn btn-right" data-tooltip="选择更低位" @click.stop="selectRight"></div>
<!-- 发射端 波形 -->
<div ref="pinTxWaveEl" class="pin" data-tooltip="发射端波形输出应连接到示波器 CH1\n提示先点一个端口再点另一个端口完成连线" style="left: 22.3%; top: 72.9%;"
@click.stop="handlePinClick('sg:tx-wave')"></div>
<!-- 发射端 换能器 -->
<div ref="pinTxTransducerEl" class="pin" data-tooltip="发射端换能器端口应连接到测定仪 左端口\n提示先点一个端口再点另一个端口完成连线" style="left: 13.2%; top: 72.9%;"
@click.stop="handlePinClick('sg:tx-transducer')"></div>
<!-- 接收端 波形 -->
<div ref="pinRxWaveEl" class="pin" data-tooltip="接收端波形输出应连接到示波器 CH2\n提示先点一个端口再点另一个端口完成连线" style="left: 34.9%; top: 72.9%;"
@click.stop="handlePinClick('sg:rx-wave')"></div>
<!-- 接收端 换能器 -->
<div ref="pinRxTransducerEl" class="pin" data-tooltip="接收端换能器端口应连接到测定仪 右端口\n提示先点一个端口再点另一个端口完成连线" style="left: 44%; top: 72.9%;"
@click.stop="handlePinClick('sg:rx-transducer')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin{
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.btn {
height: 24px;
width: 24px;
position: absolute;
cursor: pointer;
}
.sigen {
position: relative;
height: 300px;
width: fit-content;
}
.num {
font-family: 'AF-LED7 Seg-3';
position: absolute;
color: red;
font-size: 50px;
}
.num.selected.blinking {
animation: seg-blink 0.9s steps(1, end) infinite;
}
@keyframes seg-blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.num.has-dot::after {
content: '.';
position: absolute;
right: -0.6px;
bottom: 0px;
background: red;
}
.dot {
font-family: 'AF-LED7 Seg-3';
position: absolute;
color: red;
font-size: 50px;
}
img {
height: 100%;
}
</style>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import ea from '../assets/sounds';
const props = withDefaults(
defineProps<{
modelValue?: number;
}>(),
{
modelValue: 35.22,
}
);
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
(e: 'change', value: number): void;
(e: 'register-pin', payload: { id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'): void;
(e: 'pin-click', id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'): void;
}>();
const pinTxWaveEl = ref<HTMLElement | null>(null);
const pinTxTransducerEl = ref<HTMLElement | null>(null);
const pinRxWaveEl = ref<HTMLElement | null>(null);
const pinRxTransducerEl = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinTxWaveEl.value) emit('register-pin', { id: 'sg:tx-wave', el: pinTxWaveEl.value });
if (pinTxTransducerEl.value) emit('register-pin', { id: 'sg:tx-transducer', el: pinTxTransducerEl.value });
if (pinRxWaveEl.value) emit('register-pin', { id: 'sg:rx-wave', el: pinRxWaveEl.value });
if (pinRxTransducerEl.value) emit('register-pin', { id: 'sg:rx-transducer', el: pinRxTransducerEl.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'sg:tx-wave');
emit('unregister-pin', 'sg:tx-transducer');
emit('unregister-pin', 'sg:rx-wave');
emit('unregister-pin', 'sg:rx-transducer');
};
const handlePinClick = (id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer') => {
emit('pin-click', id);
};
// KHz
const freq = ref(Number(props.modelValue ?? 36.0))
watch(
() => props.modelValue,
(v) => {
if (typeof v !== 'number' || Number.isNaN(v)) return;
freq.value = clampFreq(v);
}
);
const selectedIndex = ref(4);
const isBlinking = ref(false);
let blinkTimer: number | undefined;
const resetBlinkTimer = () => {
isBlinking.value = true;
if (blinkTimer !== undefined) {
window.clearTimeout(blinkTimer);
}
blinkTimer = window.setTimeout(() => {
isBlinking.value = false;
}, 5000);
};
onMounted(() => {
// resetBlinkTimer();
nextTick().then(registerPins);
});
onBeforeUnmount(() => {
if (blinkTimer !== undefined) {
window.clearTimeout(blinkTimer);
}
unregisterPins();
});
const roundTo3 = (v: number) => Math.round(v * 1000) / 1000;
const clampFreq = (v: number) => Math.min(99.999, Math.max(0, roundTo3(v)));
const formatFreqForDisplay = (v: number) => {
const clamped = clampFreq(v);
const fixed = clamped.toFixed(3); // e.g. "9.000" / "36.125"
const [intPartRaw = '0', fracPart = '000'] = fixed.split('.');
const intPart = intPartRaw.padStart(2, '0').slice(-2);
return `${intPart}.${fracPart}`; // always "dd.ddd"
};
const stepByIndex = (idx: number) => {
// dd.ddd
// 0: (10) 1: (1) 2: 0.1 3: 0.01 4: 0.001
const steps = [10, 1, 0.1, 0.01, 0.001] as const;
const clampedIdx = Math.max(0, Math.min(4, Math.trunc(idx))) as 0 | 1 | 2 | 3 | 4;
return steps[clampedIdx];
};
const emitFreq = () => {
const v = clampFreq(freq.value);
freq.value = v;
emit('update:modelValue', v);
emit('change', v);
};
const selectLeft = () => {
selectedIndex.value = (selectedIndex.value + 4) % 5;
resetBlinkTimer();
ea.play('发生器按钮')
};
const selectRight = () => {
selectedIndex.value = (selectedIndex.value + 1) % 5;
resetBlinkTimer();
ea.play('发生器按钮')
};
const increase = () => {
freq.value = clampFreq(freq.value + stepByIndex(selectedIndex.value));
emitFreq();
resetBlinkTimer();
ea.play('发生器按钮')
};
const decrease = () => {
freq.value = clampFreq(freq.value - stepByIndex(selectedIndex.value));
emitFreq();
resetBlinkTimer();
ea.play('发生器按钮')
};
//
const digits = computed(() => {
const freqStr = formatFreqForDisplay(freq.value); // "dd.ddd"
const result: Array<{ value: string; hasDot: boolean }> = [];
let digitIndex = 0;
for (let i = 0; i < freqStr.length && digitIndex < 5; i++) {
if (freqStr[i] !== '.') {
//
const hasDot = i + 1 < freqStr.length && freqStr[i + 1] === '.';
result.push({
value: freqStr[i] as string,
hasDot: hasDot
});
digitIndex++;
}
}
// 50
while (result.length < 5) {
result.push({ value: '0', hasDot: false });
}
return result;
});
</script>

View File

@ -0,0 +1,397 @@
<template>
<div class="silver-knob-wrapper">
<div class="device-casing" @mousedown="handleMouseDown"
@touchstart.passive="handleTouchStart" @wheel.prevent="handleWheel">
<div class="indicator-mark"></div>
<div class="knob-component" ref="knobRef">
<div class="dial-face">
<div class="dial-overlay"></div>
<div class="dial-strip" :style="stripStyle">
<template v-for="(cycle, cycleIndex) in 3" :key="cycleIndex">
<div v-for="(tick, i) in config.ticksPerCycle"
:key="`${cycleIndex}-${i}`" class="tick-item"
:style="{ height: config.stepHeight + 'px' }">
<span class="tick-num">{{ i * 10 }}</span>
<div class="tick-sub-group">
<div v-for="n in config.subTicks" :key="n" class="tick-sub"
:style="getSubTickStyle(n)"></div>
</div>
</div>
</template>
</div>
</div>
<div class="knob-grip"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
initialValue: { type: Number, default: 0 },
sensitivity: { type: Number, default: 0.5 },
// 0 ~ 1000
min: { type: Number, default: 0 },
max: { type: Number, default: 30000 },
//
wheelStepUnits: { type: Number, default: 5 }
});
const emit = defineEmits(['update:value', 'rotate']);
// --- ---
const config = {
stepHeight: 40, // ( 10 )
ticksPerCycle: 10, // 10 => 100 units
containerHeight: 200,
subTicks: 9
};
const unitsPerMainTick = 10; // 1 =10 units
const cycleUnits = config.ticksPerCycle * unitsPerMainTick; // 100 units/
const pxPerUnit = config.stepHeight / unitsPerMainTick; // 4px / unit (40px/10)
const cycleHeight = config.stepHeight * config.ticksPerCycle;
// --- ---
const currentY = ref(0);
const accumulatedValue = ref(0);
const isDragging = ref(false);
let lastDragY = 0;
let startStripY = 0;
// --- utils ---
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const sanitizeBounds = () => {
// min/max
const min = Number.isFinite(props.min) ? props.min : -Infinity;
const max = Number.isFinite(props.max) ? props.max : Infinity;
return min <= max ? { min, max } : { min: max, max: min };
};
const valueToVisualOffsetPx = (value) => {
//
const mod = ((value % cycleUnits) + cycleUnits) % cycleUnits; // 0..99
return mod * pxPerUnit; // 0..(cycleUnits-1)*pxPerUnit
};
// --- ---
const stripStyle = computed(() => ({
transform: `translateY(${currentY.value}px)`,
height: `${cycleHeight * 3}px`
}));
// accumulatedValue
const displayValue = computed(() => {
const v = Math.round(accumulatedValue.value);
const mod = ((v % cycleUnits) + cycleUnits) % cycleUnits; // 0..99
const snapped = Math.round(mod / 10) * 10; // 10
return snapped === cycleUnits ? 0 : snapped; // 100 0
});
// --- ---
const getSubTickStyle = (n) => {
const gap = config.stepHeight / (config.subTicks + 1);
const topPos = n * gap;
const isMiddle = n === Math.ceil(config.subTicks / 2);
const width = isMiddle ? '16px' : '10px';
const color = isMiddle ? '#666' : '#888';
return {
top: `${topPos}px`,
width,
backgroundColor: color
};
};
//
const applyDeltaValue = (deltaValue, dragging = false) => {
if (!deltaValue) return;
const { min, max } = sanitizeBounds();
const prev = accumulatedValue.value;
const next = clamp(prev + deltaValue, min, max);
const realDeltaValue = next - prev;
//
if (realDeltaValue === 0) return;
// value -> pxvalue => Y
const realDeltaY = -(realDeltaValue * pxPerUnit);
currentY.value += realDeltaY;
accumulatedValue.value = next;
emit('rotate', {
delta: realDeltaValue,
total: accumulatedValue.value,
display: displayValue.value
});
emit('update:value', accumulatedValue.value);
// value
const topThreshold = -cycleHeight * 0.5;
const bottomThreshold = -cycleHeight * 1.5;
if (currentY.value > topThreshold) {
currentY.value -= cycleHeight;
if (dragging) startStripY -= cycleHeight;
} else if (currentY.value < bottomThreshold) {
currentY.value += cycleHeight;
if (dragging) startStripY += cycleHeight;
}
};
// updateView(deltaY px) px value applyDeltaValue
const updateViewByDeltaY = (deltaY, dragging = false) => {
// (Y) ->
const deltaValue = -(deltaY / config.stepHeight) * unitsPerMainTick; // -(deltaY/pxPerUnit)
applyDeltaValue(deltaValue, dragging);
};
// --- ---
onMounted(() => {
const { min, max } = sanitizeBounds();
accumulatedValue.value = clamp(props.initialValue, min, max);
const containerCenterOffset = config.containerHeight / 2;
const visualOffsetPx = valueToVisualOffsetPx(accumulatedValue.value);
//
currentY.value = -cycleHeight + containerCenterOffset - visualOffsetPx;
window.addEventListener('mousemove', handleGlobalMouseMove);
window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('touchmove', handleGlobalTouchMove, { passive: false });
window.addEventListener('touchend', handleGlobalMouseUp);
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleGlobalMouseMove);
window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('touchmove', handleGlobalTouchMove);
window.removeEventListener('touchend', handleGlobalMouseUp);
});
// initialValue
watch(
() => props.initialValue,
(v) => {
const { min, max } = sanitizeBounds();
accumulatedValue.value = clamp(v, min, max);
const containerCenterOffset = config.containerHeight / 2;
const visualOffsetPx = valueToVisualOffsetPx(accumulatedValue.value);
currentY.value = -cycleHeight + containerCenterOffset - visualOffsetPx;
}
);
// --- ---
const handleMouseDown = (e) => {
isDragging.value = true;
startStripY = currentY.value;
lastDragY = e.clientY;
document.body.style.cursor = 'grabbing';
e.preventDefault();
};
const handleTouchStart = (e) => {
isDragging.value = true;
startStripY = currentY.value;
lastDragY = e.touches[0].clientY;
};
const handleGlobalMouseMove = (e) => {
if (!isDragging.value) return;
const deltaY = (e.clientY - lastDragY) * props.sensitivity;
lastDragY = e.clientY;
updateViewByDeltaY(deltaY, true);
};
const handleGlobalTouchMove = (e) => {
if (!isDragging.value) return;
const clientY = e.touches[0].clientY;
const deltaY = (clientY - lastDragY) * props.sensitivity;
lastDragY = clientY;
updateViewByDeltaY(deltaY, true);
if (e.cancelable) e.preventDefault();
};
const handleGlobalMouseUp = () => {
isDragging.value = false;
document.body.style.cursor = '';
};
const handleWheel = (e) => {
//
// e.deltaY > 0
const deltaValue = e.deltaY > 0 ? props.wheelStepUnits : -props.wheelStepUnits;
applyDeltaValue(deltaValue, false);
};
</script>
<style scoped>
/* 样式保持不变,已包含在上面 */
.silver-knob-wrapper {
display: inline-block;
}
.device-casing {
padding: 0px 0 0 6px;
display: flex;
align-items: center;
position: relative;
user-select: none;
}
.knob-component {
display: flex;
height: 200px;
perspective: 1000px;
cursor: ns-resize;
position: relative;
filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.3));
}
.knob-component:active {
cursor: grabbing;
}
.dial-face {
width: 80px;
height: 100%;
background: linear-gradient(to right, #999, #f0f0f0 40%, #ddd 100%);
position: relative;
overflow: hidden;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-left: 1px solid #888;
}
.dial-strip {
position: absolute;
left: 0;
width: 100%;
will-change: transform;
}
.tick-item {
display: flex;
align-items: center;
padding-left: 30px;
position: relative;
box-sizing: border-box;
}
.tick-num {
color: #333;
font-size: 18px;
font-family: Arial, sans-serif;
font-weight: bold;
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
margin-top: -20px;
margin-left: 5px;
}
.tick-item::before {
content: '';
position: absolute;
left: 0;
top: -1px;
width: 25px;
height: 2px;
background-color: #444;
z-index: 1;
}
.tick-sub-group {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.tick-sub {
position: absolute;
left: 0;
height: 1px;
background-color: #888;
}
.dial-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(to bottom, rgba(100, 100, 100, 0.8) 0%, rgba(255, 255, 255, 0.3) 20%, rgba(255, 255, 255, 0.0) 50%, rgba(255, 255, 255, 0.3) 80%, rgba(100, 100, 100, 0.8) 100%);
z-index: 10;
mix-blend-mode: multiply;
}
.knob-grip {
width: 25px;
height: 100%;
background-color: #ccc;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
position: relative;
border: 1px solid #bbb;
border-left: none;
background-image:
linear-gradient(90deg, rgba(150, 150, 150, 1) 0%, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 1) 55%, rgba(180, 180, 180, 1) 100%),
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(50, 50, 50, 0.3) 2px, rgba(50, 50, 50, 0.4) 4px),
repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(50, 50, 50, 0.3) 2px, rgba(50, 50, 50, 0.4) 4px);
background-size: 100% 100%, 10px 10px, 10px 10px;
background-blend-mode: multiply;
}
.knob-grip::before,
.knob-grip::after {
content: '';
position: absolute;
width: 100%;
height: 15px;
background: linear-gradient(to bottom, rgba(100, 100, 100, 0.5), transparent);
left: 0;
border-top-right-radius: 12px;
}
.knob-grip::before {
top: 0;
}
.knob-grip::after {
bottom: 0;
transform: scaleY(-1);
border-bottom-right-radius: 12px;
border-top-right-radius: 0;
}
.indicator-mark {
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-50%);
width: 25px;
height: 4px;
background: #d00;
z-index: 20;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
clip-path: polygon(0 0, 85% 0, 100% 50%, 85% 100%, 0 100%);
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div class="slider-wrapper" :style="wrapperStyle">
<div class="slider-inner" ref="containerRef" :style="innerStyle">
<div class="track-slot"></div>
<div
class="knob"
:style="knobStyle"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
>
<div class="knob-face"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
// scale 1 ()
scale: {
type: Number,
default: 1.0
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// --- () ---
const ORIGINAL_WIDTH = 40; // px
const ORIGINAL_HEIGHT = 150; // px
const STEPS = 4;
const STEP_DISTANCE = 40;
const KNOB_HEIGHT = 24;
const TRACK_PADDING_TOP = 2;
// --- ---
const currentIndex = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
currentIndex.value = newVal;
});
// --- ---
// 1.
const wrapperStyle = computed(() => ({
width: `${ORIGINAL_WIDTH * props.scale}px`,
height: `${ORIGINAL_HEIGHT * props.scale}px`,
}));
// 2. 使 scale
const innerStyle = computed(() => ({
width: `${ORIGINAL_WIDTH}px`,
height: `${ORIGINAL_HEIGHT}px`,
transform: `scale(${props.scale})`,
transformOrigin: 'top left' //
}));
// 3.
const knobStyle = computed(() => {
const topOffset = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
return {
transform: `translateY(${topOffset}px)`
};
});
// --- () ---
let isDragging = false;
let startMouseY = 0;
let startKnobTop = 0;
const startDrag = (e) => {
isDragging = true;
startMouseY = e.clientY || e.touches[0].clientY;
startKnobTop = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag);
window.addEventListener('touchmove', onDrag, { passive: false });
window.addEventListener('touchend', stopDrag);
};
const onDrag = (e) => {
if (!isDragging) return;
if (e.cancelable) e.preventDefault();
const currentMouseY = e.clientY || e.touches[0].clientY;
// []
// (screen delta)
const screenDeltaY = currentMouseY - startMouseY;
// (internal delta)
// 0.5 10px 20px
const internalDeltaY = screenDeltaY / props.scale;
let newRawTop = startKnobTop + internalDeltaY;
//
let relativeTop = newRawTop - TRACK_PADDING_TOP;
let newIndex = Math.round(relativeTop / STEP_DISTANCE);
const maxIndex = STEPS - 1;
if (newIndex < 0) newIndex = 0;
if (newIndex > maxIndex) newIndex = maxIndex;
if (newIndex !== currentIndex.value) {
currentIndex.value = newIndex;
emit('update:modelValue', newIndex);
emit('change', newIndex);
}
};
const stopDrag = () => {
isDragging = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('touchmove', onDrag);
window.removeEventListener('touchend', stopDrag);
};
</script>
<style scoped>
/* slider-inner
保持原本的尺寸定义即可缩放由内联样式 style 处理
*/
.slider-wrapper {
/* 防止溢出裁剪阴影 */
overflow: visible;
/* 禁止选中 */
user-select: none;
touch-action: none;
/* 方便父级布局 */
display: inline-block;
vertical-align: middle;
}
.slider-inner {
position: relative;
/* 这里的宽高现在由 innerStyle 动态控制,但写上默认值是个好习惯 */
width: 40px;
height: 150px;
}
.track-slot {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 8px;
background-color: #1a1a1a;
border-radius: 4px;
box-shadow:
inset 1px 1px 3px rgba(0,0,0,0.7),
inset -1px -1px 1px rgba(255,255,255,0.1);
}
.knob {
position: absolute;
top: 0;
left: 50%;
margin-left: -23px;
width: 46px;
height: 24px;
z-index: 10;
cursor: grab;
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1);
backface-visibility: hidden; /* 优化缩放后的渲染 */
}
.knob:active {
cursor: grabbing;
}
.knob-face {
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #ffffff 0%, #e4e4e4 100%);
border-radius: 4px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,1),
inset 0 -1px 0 rgba(0,0,0,0.1),
0 3px 6px rgba(0,0,0,0.35),
0 0 0 1px rgba(0,0,0,0.08);
}
</style>

6
src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import './assets/stylesheet.css'
import App from './App.vue'
createApp(App).mount('#app')

38
src/style.css Normal file
View File

@ -0,0 +1,38 @@
body{
margin: 0;
}
*{
user-select: none;
box-sizing: border-box;
}
*[data-tooltip] {
touch-action: none;
}
/* ===== 接线 pin 交互样式(全局) ===== */
.pin {
background-color: transparent !important;
border: 2px dashed transparent;
border-radius: 50%;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.pin:hover {
border-color: rgba(59, 130, 246, 0.95);
background-color: rgba(255, 255, 255, 0.06);
}
.pin.pin-connecting {
border-color: rgba(59, 130, 246, 0.95);
background-color: rgba(59, 130, 246, 0.10);
box-shadow:
0 0 0 3px rgba(59, 130, 246, 0.25),
0 0 14px rgba(59, 130, 246, 0.55);
}
.btn, .xymode, .power-btn{
touch-action: none;
}

16
tsconfig.app.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

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

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0'
}
})