first commit
1
.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://feie9454:zjh94544549OK%3F@100.64.0.5:5432/ssd"
|
||||
24
.gitignore
vendored
Normal 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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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 });
|
||||
|
||||
// 基于bottom值自动计算zIndex:bottom越小(越靠下),zIndex越大
|
||||
const getAutoZIndex = (instrument: InstrumentState) => {
|
||||
// 将bottom值反转,使得bottom=0时zIndex最大
|
||||
return Math.round(1000 - instrument.bottom * 10);
|
||||
};
|
||||
|
||||
// 透视计算:bottom越小(越靠下),scale应该越大(近大远小)
|
||||
// bottom范围: 0-100%, 映射到scale: 1.5-0.5倍
|
||||
const getPerspectiveScale = (instrument: InstrumentState) => {
|
||||
// bottom越小越靠下,scale越大
|
||||
const perspectiveFactor = 1.5 - (instrument.bottom / 100) * 1.0; // bottom=0时1.5, bottom=100时0.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; // Y轴向下为正,bottom向上为正
|
||||
|
||||
// 更新位置
|
||||
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>
|
||||
BIN
src/assets/AF-LED7Seg-3.woff2
Normal file
BIN
src/assets/PIXymbolsDigitClocksW90-Bd.woff2
Normal file
14
src/assets/sounds/index.ts
Normal 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
@ -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();
|
||||
}
|
||||
}
|
||||
BIN
src/assets/sounds/发生器按钮.mp3
Normal file
BIN
src/assets/sounds/换挡.mp3
Normal file
BIN
src/assets/sounds/电源按钮.mp3
Normal file
BIN
src/assets/sounds/线缆插入.mp3
Normal file
8
src/assets/stylesheet.css
Normal 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
@ -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
|
After Width: | Height: | Size: 1.5 MiB |
BIN
src/assets/仪器.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
src/assets/仪器臂.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
src/assets/仪器臂.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/信号发生.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
src/assets/信号发生.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
src/assets/入门引导.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
src/assets/入门引导.webp
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
src/assets/按钮按下.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
src/assets/按钮按下.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/桌面.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
src/assets/桌面.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
src/assets/示波器.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
src/assets/示波器.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
121
src/components/DeterminationMachine.vue
Normal 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>
|
||||
186
src/components/MetalRuler.vue
Normal 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
@ -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.980–36.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>
|
||||
414
src/components/Oscilloscope.vue
Normal 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; // Hz,与props.frequency保持一致
|
||||
const Q_FACTOR = 100; // 品质因数(越大则频率影响越强:偏离固有频率时衰减更快)
|
||||
|
||||
// 核心物理计算
|
||||
function calculatePhysics() {
|
||||
// 将距离从mm转换为cm,再转换为m
|
||||
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-320映射到0.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>
|
||||
386
src/components/RetroKnob.vue
Normal 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>
|
||||
267
src/components/SignalGen.vue
Normal 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++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不足5位,补0
|
||||
while (result.length < 5) {
|
||||
result.push({ value: '0', hasDot: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
</script>
|
||||
397
src/components/SilverKnob.vue
Normal 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 -> px:value 增加 => 条带上移(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>
|
||||
198
src/components/SwitchLever.vue
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal 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
@ -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'
|
||||
}
|
||||
})
|
||||