记录与数据处理

This commit is contained in:
feie9454 2026-01-28 14:43:20 +08:00
parent 73f948d4fc
commit a228647152
30 changed files with 1920 additions and 534 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DEPLOY_PASSWORD=zjh94544549ok

View File

@ -7,6 +7,7 @@
"dependencies": {
"@types/qrcode": "^1.5.6",
"echarts": "^6.0.0",
"katex": "^0.16.22",
"lucide-vue-next": "^0.562.0",
"qrcode": "^1.5.4",
"vue": "^3.5.24",
@ -16,6 +17,7 @@
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.97.2",
"ssh2-sftp-client": "^11.0.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4",
@ -209,8 +211,16 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@ -223,6 +233,12 @@
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
@ -237,6 +253,8 @@
"entities": ["entities@7.0.0", "", {}, "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
"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=="],
@ -253,12 +271,16 @@
"immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lucide-vue-next": ["lucide-vue-next@0.562.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q=="],
@ -267,6 +289,8 @@
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
"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=="],
@ -289,18 +313,28 @@
"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=="],
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"rollup": ["rollup@4.55.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.2", "@rollup/rollup-android-arm64": "4.55.2", "@rollup/rollup-darwin-arm64": "4.55.2", "@rollup/rollup-darwin-x64": "4.55.2", "@rollup/rollup-freebsd-arm64": "4.55.2", "@rollup/rollup-freebsd-x64": "4.55.2", "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", "@rollup/rollup-linux-arm-musleabihf": "4.55.2", "@rollup/rollup-linux-arm64-gnu": "4.55.2", "@rollup/rollup-linux-arm64-musl": "4.55.2", "@rollup/rollup-linux-loong64-gnu": "4.55.2", "@rollup/rollup-linux-loong64-musl": "4.55.2", "@rollup/rollup-linux-ppc64-gnu": "4.55.2", "@rollup/rollup-linux-ppc64-musl": "4.55.2", "@rollup/rollup-linux-riscv64-gnu": "4.55.2", "@rollup/rollup-linux-riscv64-musl": "4.55.2", "@rollup/rollup-linux-s390x-gnu": "4.55.2", "@rollup/rollup-linux-x64-gnu": "4.55.2", "@rollup/rollup-linux-x64-musl": "4.55.2", "@rollup/rollup-openbsd-x64": "4.55.2", "@rollup/rollup-openharmony-arm64": "4.55.2", "@rollup/rollup-win32-arm64-msvc": "4.55.2", "@rollup/rollup-win32-ia32-msvc": "4.55.2", "@rollup/rollup-win32-x64-gnu": "4.55.2", "@rollup/rollup-win32-x64-msvc": "4.55.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sass": ["sass@1.97.2", "", { "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-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw=="],
"sass-embedded": ["sass-embedded@1.97.2", "", { "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.2", "sass-embedded-android-arm": "1.97.2", "sass-embedded-android-arm64": "1.97.2", "sass-embedded-android-riscv64": "1.97.2", "sass-embedded-android-x64": "1.97.2", "sass-embedded-darwin-arm64": "1.97.2", "sass-embedded-darwin-x64": "1.97.2", "sass-embedded-linux-arm": "1.97.2", "sass-embedded-linux-arm64": "1.97.2", "sass-embedded-linux-musl-arm": "1.97.2", "sass-embedded-linux-musl-arm64": "1.97.2", "sass-embedded-linux-musl-riscv64": "1.97.2", "sass-embedded-linux-musl-x64": "1.97.2", "sass-embedded-linux-riscv64": "1.97.2", "sass-embedded-linux-x64": "1.97.2", "sass-embedded-unknown-all": "1.97.2", "sass-embedded-win32-arm64": "1.97.2", "sass-embedded-win32-x64": "1.97.2" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg=="],
@ -345,8 +379,14 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"ssh2-sftp-client": ["ssh2-sftp-client@11.0.0", "", { "dependencies": { "concat-stream": "^2.0.0", "promise-retry": "^2.0.1", "ssh2": "^1.15.0" } }, "sha512-lOjgNYtioYquhtgyHwPryFNhllkuENjvCKkUXo18w/Q4UpEffCnEUBfiOTlwFdKIhG1rhrOGnA6DeKPSF2CP6w=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
@ -359,10 +399,16 @@
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"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=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
"vite": ["vite@7.3.1", "", { "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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],

View File

@ -2,9 +2,8 @@
<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>motor-ui</title>
<title>自动声速测定仪 - 首页</title>
</head>
<body>
<div id="app"></div>

View File

@ -6,11 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"upload": "bun scripts/deploy.mjs",
"deploy": "bun run build && bun run upload"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"echarts": "^6.0.0",
"katex": "^0.16.22",
"lucide-vue-next": "^0.562.0",
"qrcode": "^1.5.4",
"vue": "^3.5.24"
@ -20,6 +23,7 @@
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.97.2",
"ssh2-sftp-client": "^11.0.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

49
scripts/deploy.mjs Normal file
View File

@ -0,0 +1,49 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Client from 'ssh2-sftp-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const HOST = process.env.DEPLOY_HOST ?? '192.168.1.71';
const USERNAME = process.env.DEPLOY_USER ?? 'feie9454';
const REMOTE_DIR = process.env.DEPLOY_PATH ?? '/var/www/html';
const PASSWORD = process.env.DEPLOY_PASSWORD;
if (!PASSWORD) {
console.error('Missing DEPLOY_PASSWORD env var.');
console.error('Example (PowerShell): $env:DEPLOY_PASSWORD="<your password>"; npm run deploy');
process.exit(1);
}
const localDist = path.resolve(__dirname, '..', 'dist');
const sftp = new Client();
try {
console.log(`Deploying ${localDist} -> ${USERNAME}@${HOST}:${REMOTE_DIR}`);
await sftp.connect({
host: HOST,
username: USERNAME,
password: PASSWORD,
readyTimeout: 20000,
});
// Ensure remote directory exists
await sftp.mkdir(REMOTE_DIR, true);
// Upload dist contents into REMOTE_DIR
await sftp.uploadDir(localDist, REMOTE_DIR);
console.log('Deploy complete.');
} catch (err) {
console.error('Deploy failed:', err?.message ?? err);
process.exitCode = 1;
} finally {
try {
await sftp.end();
} catch {
// ignore
}
}

View File

@ -1,12 +1,10 @@
<script setup lang="ts">
import { reactive, ref, type Ref, watch } from 'vue';
import * as api from './api.ts'
import type { HistoryItem } from './types.ts'
import Button from './components/Button.vue';
import Machine from './components/Machine.vue';
import QRCode from 'qrcode';
import Panel from './components/Panel.vue';
import Window from './components/Window.vue';
import Oscilloscope from './components/Oscilloscope.vue';
const state = reactive({ ...api.getState() });
@ -26,18 +24,21 @@ const showLogin = ref(false);
const showManualAdjust = ref(false);
const targetDis = ref('');
import { Delete, RefreshCcw, Settings, ChevronRight } from 'lucide-vue-next';
import { Delete, RefreshCcw, Settings, ChevronRight, LogOut } from 'lucide-vue-next';
import { showMessage } from './utils/message';
import Numpad from './components/Numpad.vue';
import Control from './pages/Control.vue';
import History from './pages/History.vue';
import Home from './pages/Home.vue';
import Measure from './pages/Measure.vue';
import TrendChart from './components/TrendChart.vue';
import DataProcess from './pages/DataProcess.vue';
const showSettings = ref(false);
const showSpeedSettings = ref(false);
const targetSpeed = ref('');
const historyData = ref<any[]>([]);
const historyData = ref<HistoryItem[]>([]);
//
watch(() => state.last_measurement, (newVal, oldVal) => {
@ -116,28 +117,34 @@ function locationReload() {
location.reload();
}
// 'home' | 'measure' | 'control' | 'history'
const pageIndex: Ref<0 | 1 | 2 | 3> = ref(0);
function exitSystem() {
document.exitFullscreen();
window.close();
}
const mountedPages = ref([true, false, false, false]);
const pageNames = ['首页', '波形', '控制', '记录', '处理'];
const pageIndex: Ref<0 | 1 | 2 | 3 | 4> = ref(0);
const mountedPages = ref([true, false, false, false, false]);
let cleanupTimer: any = null;
watch(pageIndex, (newVal, oldVal) => {
// Ensure target page is mounted
mountedPages.value[newVal] = true;
// Ensure target page is mounted
document.title = `自动声速测定仪 - ${pageNames[newVal]}`;
mountedPages.value[newVal] = true;
// Ensure old page stays mounted during transition
if (oldVal !== undefined) {
mountedPages.value[oldVal] = true;
// Ensure old page stays mounted during transition
if (oldVal !== undefined) {
mountedPages.value[oldVal] = true;
}
if (cleanupTimer) clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// Keep only the current page mounted to save resources
for (let i = 0; i < 5; i++) {
mountedPages.value[i] = (i === pageIndex.value);
}
if (cleanupTimer) clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// Keep only the current page mounted to save resources
for (let i = 0; i < 4; i++) {
mountedPages.value[i] = (i === pageIndex.value);
}
}, 400); // 0.3s transition + buffer
}, 400); // 0.3s transition + buffer
});
</script>
@ -145,12 +152,16 @@ watch(pageIndex, (newVal, oldVal) => {
<header>
<div class="page-label">
<Button :class="{ current: pageIndex === 0 }" @click="pageIndex = 0">首页</Button>
<Button :class="{ current: pageIndex === 1 }" @click="pageIndex = 1">测量</Button>
<Button :class="{ current: pageIndex === 1 }" @click="pageIndex = 1">波形</Button>
<Button :class="{ current: pageIndex === 2 }" @click="pageIndex = 2">控制</Button>
<Button :class="{ current: pageIndex === 3 }" @click="pageIndex = 3">历史</Button>
<Button :class="{ current: pageIndex === 3 }" @click="pageIndex = 3">记录</Button>
<Button :class="{ current: pageIndex === 4 }" @click="pageIndex = 4">处理</Button>
</div>
<div class="page-title">
<div class="connection-state" :class="{ connected: state.connected, disconnected: !state.connected }"></div>
自动声速测定仪
</div>
<div class="connection-state" :class="{ connected: state.connected, disconnected: !state.connected }"></div>
<span>自动声速测定仪</span>
<div class="actions">
<Button class="reload" @click="locationReload">
<RefreshCcw />
@ -163,57 +174,22 @@ watch(pageIndex, (newVal, oldVal) => {
</header>
<main>
<div class="page page-home" :style="{ transform: `translateX(${(-pageIndex - 0) * 100}%)` }">
<template v-if="mountedPages[0]">
<div class="left">
<Panel style="flex: 1;">
<TrendChart :data="historyData" />
</Panel>
<Panel>
<Machine :dis="state.dis"
:task="state.tasks.reduce((acc, task) => (task.type == 'move' ? ({ remaining_steps: acc.remaining_steps + task.remaining_steps }) : acc), { remaining_steps: 0 })" />
</Panel>
</div>
<div class="right">
<div class="label">相位差</div>
<div class="data">{{ state.phase.toFixed(4) }} rad</div>
<div class="label">频率</div>
<div class="data">{{ state.freq }} KHz</div>
<div class="label">峰峰值</div>
<div class="data">{{ state.p2p }} ADC</div>
<div class="action">
<div class="left">
<div class="line">
<Button class="man-adj" @long-press="api.move(-40)"></Button>
<span>手动<br>调整</span>
<Button class="man-adj" @long-press="api.move(40)"></Button>
</div>
<Button class="input-dis" @click="showManualAdjust = true">
输入
</Button>
</div>
<div class="right">
<Button class="action-btn" @click="pageIndex = 2" bg="limegreen">开始实验</Button>
<!-- <Button class="action-btn" @click="api.measure()" bg="limegreen">单次测量</Button> -->
<Button class="action-btn" @click="api.stopAll()" bg="red">停止</Button>
</div>
</div>
</div>
</template>
<Home v-if="mountedPages[0]" :state="state" :history-data="historyData"
@show-manual-adjust="showManualAdjust = true" @change-page="pageIndex = $event" />
</div>
<div class="page page-measure" :style="{ transform: `translateX(${(-pageIndex + 1) * 100}%)` }">
<Oscilloscope v-if="mountedPages[1]" :data="state.last_measurement">
<template #controls>
<Button @click="api.measure()" bg="limegreen" style="height: 52px; width: 100px; border-radius: 8px;">单次测量</Button>
</template>
</Oscilloscope>
<Measure v-if="mountedPages[1]" :state="state" />
</div>
<div class="page page-control" :style="{ transform: `translateX(${(-pageIndex + 2) * 100}%)` }">
<Control v-if="mountedPages[2]" :state="state" @start="clearHistory" />
<Control v-if="mountedPages[2]" :state="state" @start="clearHistory"
@show-motor-speed-setting="showSpeedSettings = true" @show-manual-adjust="showManualAdjust = true" />
</div>
<div class="page page-history" :style="{ transform: `translateX(${(-pageIndex + 3) * 100}%)` }">
<History v-if="mountedPages[3]" :history="historyData" @clear="clearHistory" />
</div>
<div class="page page-data-process" :style="{ transform: `translateX(${(-pageIndex + 4) * 100}%)` }">
<DataProcess v-if="mountedPages[4]" :history-data="historyData" />
</div>
</main>
<Window v-model="showLogin" title="扫码登录">
<img :src="qrcodeSrc" alt="" class="qr-img">
@ -239,6 +215,12 @@ watch(pageIndex, (newVal, oldVal) => {
<ChevronRight :size="20" class="arrow" />
</div>
</Button>
<Button class="settings-item" @click="exitSystem">
<span class="settings-label">退出系统</span>
<div class="settings-value">
<LogOut :size="20" class="arrow" />
</div>
</Button>
</div>
</Window>
@ -262,22 +244,8 @@ header {
align-items: center;
color: white;
position: relative;
padding-left: 40px;
.connection-state {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: gray;
margin-right: 8px;
&.connected {
background-color: limegreen;
}
&.disconnected {
background-color: red;
}
}
.actions {
display: flex;
@ -305,6 +273,29 @@ header {
}
}
.page-title {
display: flex;
align-items: center;
gap: 6px;
.connection-state {
position: relative;
top: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: gray;
&.connected {
background-color: limegreen;
}
&.disconnected {
background-color: red;
}
}
}
.page-label {
position: absolute;
left: 12px;
@ -314,7 +305,7 @@ header {
gap: 4px;
.btn {
width: 64px;
width: 56px;
height: 28px;
border: none;
@ -354,52 +345,10 @@ header {
}
}
.action {
display: flex;
justify-content: space-between;
width: 100%;
.man-adj {
width: 52px;
height: 52px;
font-size: 24px;
margin-bottom: 6px;
}
.left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
.line {
display: flex;
align-items: center;
gap: 4px;
}
}
.right {
display: flex;
flex-direction: column;
gap: 6px;
height: 150px;
justify-content: center;
}
.action-btn {
width: 140px;
height: 52px;
border-radius: 8px;
font-size: 20px;
}
}
main {
overflow: hidden;
height: calc(100vh - 36px);
width: 100vw;
height: calc(100% - 36px);
width: 100%;
position: relative;
}
@ -419,43 +368,6 @@ main {
height: 160px;
}
.page-home>.left,
.page-home>.right {
display: flex;
flex-direction: column;
padding: 10px;
}
.page-home>.left {
flex: 3;
position: relative;
align-items: stretch;
gap: 8px;
}
.page-home>.right {
flex: 2;
position: relative;
align-items: center;
.label {
align-self: flex-start;
font-size: 24px;
}
.label::after {
content: '';
margin-left: 4px;
}
.data {
font-size: 32px;
font-weight: bold;
margin-bottom: 12px;
margin-top: -6px;
}
}
.manual-adjust-content {
display: flex;
gap: 16px;

View File

@ -1,4 +1,8 @@
const API_BASE = 'http://localhost:8000';
import type { AppState, BatchTask } from './types';
const url = new URL(window.location.origin);
url.port = '8000';
const API_BASE = url.toString().endsWith('/') ? url.toString().slice(0, -1) : url.toString();
async function ping() {
return await fetch(`${API_BASE}/ping`);
@ -8,55 +12,36 @@ async function bee() {
return await fetch(`${API_BASE}/bee`);
}
const _state = {
// 连接状态
const _state: AppState = {
connected: false,
// 接收器距离 microsteps
dis: 16000,
// 相位差 rad
phase: 0,
// 频率 KHz
freq: 0,
// 峰峰值 mV
p2p: 0,
// 电机速度 Hz 1600 means 1mm/s or 1000 um/s
speed: 1200,
// 任务
total_tasks: 0,
tasks: [] as ({
id: string;
type: 'move';
steps: number;
remaining_steps: number;
status: 'running' | 'pending' | 'queued';
created_at: number;
} | {
id: string;
type: 'measure';
status: 'running' | 'pending' | 'queued';
created_at: number;
})[],
last_measurement:{}as {
"ts": number,
"idn": null,
"points_mode": 'NORM',
"n": number,
"tscale": number,
"toffs": number,
"f0_hz": number,
"amp1_pp_adc": number,
"amp2_pp_adc": number,
"phi1_rad": number,
"phi2_rad": number,
"dphi_rad": number,
"dphi_deg": number,
"dt_s": number,
"wave1": number[],
"wave2": number[],
tasks: [],
last_measurement: {
ts: 0,
idn: null,
points_mode: 'NORM',
n: 0,
tscale: 0,
toffs: 0,
f0_hz: 0,
amp1_pp_adc: 0,
amp2_pp_adc: 0,
phi1_rad: 0,
phi2_rad: 0,
dphi_rad: 0,
dphi_deg: 0,
dt_s: 0,
wave1: [],
wave2: [],
}
}
const listeners: ((state: typeof _state) => void)[] = [];
const listeners: ((state: AppState) => void)[] = [];
function notifyListeners() {
for (const listener of listeners) {
@ -65,7 +50,7 @@ function notifyListeners() {
}
function initSSE() {
let reconnectTimer: NodeJS.Timeout;
let reconnectTimer: any;
const connect = () => {
const eventSource = new EventSource(`${API_BASE}/events`);
@ -114,7 +99,7 @@ function getState() {
return _state;
}
async function onStateChange(callback: (state: typeof _state) => void) {
async function onStateChange(callback: (state: AppState) => void) {
listeners.push(callback);
// 立即回调当前状态
callback(_state);
@ -175,7 +160,7 @@ async function measure() {
});
}
async function batch(tasks: any[]) {
async function batch(tasks: BatchTask[]) {
return await fetch(`${API_BASE}/action/batch`, {
method: 'POST',
headers: {

Binary file not shown.

View File

@ -1,35 +0,0 @@
<svg width="600" height="200" viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg">
<g id="rail_layer">
<line x1="50" y1="160" x2="550" y2="160" stroke="#4a5568" stroke-width="4" stroke-linecap="round"/>
<line x1="100" y1="160" x2="100" y2="170" stroke="#4a5568" stroke-width="2"/>
<line x1="200" y1="160" x2="200" y2="170" stroke="#4a5568" stroke-width="2"/>
<line x1="300" y1="160" x2="300" y2="170" stroke="#4a5568" stroke-width="2"/>
<line x1="400" y1="160" x2="400" y2="170" stroke="#4a5568" stroke-width="2"/>
<line x1="500" y1="160" x2="500" y2="170" stroke="#4a5568" stroke-width="2"/>
</g>
<g id="emitter_group" transform="translate(80, 100)">
<rect x="-15" y="30" width="30" height="30" rx="4" fill="#2b6cb0" />
<line x1="0" y1="30" x2="0" y2="0" stroke="#2b6cb0" stroke-width="4"/>
<circle cx="0" cy="0" r="18" fill="#4299e1" stroke="#2b6cb0" stroke-width="3"/>
<circle cx="0" cy="0" r="8" fill="#bee3f8"/>
<text x="0" y="-30" text-anchor="middle" font-family="Arial" font-size="12" fill="#4299e1" font-weight="bold">S1 发射</text>
</g>
<g id="waves" stroke="#63b3ed" stroke-width="2" stroke-opacity="0.5" fill="none">
<path d="M 110 100 Q 130 100 150 100" stroke-dasharray="4,4" />
</g>
<g id="receiver_group" transform="translate(300, 100)">
<line x1="-220" y1="0" x2="-18" y2="0" stroke="#ed8936" stroke-width="1" stroke-dasharray="5,5" opacity="0.6"/>
<rect x="-15" y="30" width="30" height="30" rx="4" fill="#c05621" />
<line x1="0" y1="30" x2="0" y2="0" stroke="#c05621" stroke-width="4"/>
<path d="M -16 0 L -8 -14 L 8 -14 L 16 0 L 8 14 L -8 14 Z" fill="#ed8936" stroke="#c05621" stroke-width="3"/>
<circle cx="0" cy="0" r="6" fill="#feebc8"/>
<text x="0" y="-30" text-anchor="middle" font-family="Arial" font-size="12" fill="#ed8936" font-weight="bold">S2 接收</text>
<rect x="-30" y="65" width="60" height="20" rx="4" fill="#4a5568"/>
<text x="0" y="79" text-anchor="middle" font-family="Monospace" font-size="10" fill="#ffffff">L=Moving</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,8 +0,0 @@
@font-face {
font-family: 'PingFang SC';
src: url('_-.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import katex from 'katex';
const props = withDefaults(
defineProps<{
tex: string;
displayMode?: boolean;
}>(),
{
displayMode: false,
}
);
const html = computed(() => {
try {
return katex.renderToString(props.tex, {
displayMode: props.displayMode,
throwOnError: false,
strict: 'ignore',
trust: false,
output: 'html',
});
} catch {
return '';
}
});
</script>
<template>
<span class="formula" :class="{ block: displayMode }" v-html="html"></span>
</template>
<style scoped>
.formula {
display: inline-block;
vertical-align: -0.12em;
}
.formula.block {
display: block;
}
</style>

View File

@ -84,6 +84,7 @@ img {
width: 100%;
text-align: center;
font-weight: bold;
font-size: 13px;
}
.machine {

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import * as api from '../api.ts';
import Button from './Button.vue';
const emit = defineEmits<{
(e: 'showInput'): void;
}>();
import { ArrowLeft, ArrowRight } from 'lucide-vue-next';
</script>
<template>
<div class="manual-adjust">
<div class="line">
<Button class="man-adj" @long-press="api.move(-40)"><ArrowLeft /></Button>
<span>手动<br>调整</span>
<Button class="man-adj" @long-press="api.move(40)"><ArrowRight /></Button>
</div>
<Button class="input-dis" @click="emit('showInput')">
输入
</Button>
</div>
</template>
<style scoped lang="scss">
.manual-adjust {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
.line {
display: flex;
align-items: center;
gap: 4px;
}
.man-adj {
width: 52px;
height: 52px;
font-size: 24px;
margin-bottom: 6px;
}
.input-dis {
width: 80%;
}
}
</style>

View File

@ -29,7 +29,7 @@ const inputNumber = (val: string, current: string) => {
<div class="numpad-container">
<div class="label">{{ label }}</div>
<div class="input-wrapper">
<input type="text" :value="modelValue" disabled class="target-input">
<div class="target-input" aria-disabled="true">{{ modelValue || '\u00A0' }}</div>
<span class="unit">{{ unit }}</span>
</div>
<div class="input-panel">
@ -86,10 +86,12 @@ const inputNumber = (val: string, current: string) => {
.target-input {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
text-align: center;
border: 2px solid #808080;
background-color: white;
background-color: white !important;
padding: 4px 8px;
box-sizing: border-box;
border-radius: 4px;

View File

@ -19,29 +19,41 @@ const props = defineProps<{
data: HistoryItem[];
}>();
const emit = defineEmits<{
(e: 'select', index: number): void;
}>();
const chart = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
let chartIndexToRawIndex: number[] = [];
let rawIndexToChartIndex: number[] = [];
const initChart = () => {
if (chart.value && !chartInstance) {
chartInstance = echarts.init(chart.value);
chartInstance.on('click', (params) => {
if (params.dataIndex !== undefined) {
emit('select', params.dataIndex);
}
});
}
};
const updateChart = () => {
if (!chartInstance) return;
const dataPhase = props.data.map(item => [(item.currentDis / 1600).toFixed(3), item.dphi_deg]);
// 线
const dataPhase: ([(string | number), number] | null)[] = [];
const dataP2P = props.data.map(item => [(item.currentDis / 1600).toFixed(3), item.amp2_pp_adc]);
chartIndexToRawIndex = [];
rawIndexToChartIndex = [];
for (let i = 0; i < props.data.length; i++) {
const item = props.data[i]!;
const x = (item.currentDis / 1600).toFixed(3);
const y = item.dphi_deg;
// 180
if (i > 0 && Math.abs(y - props.data[i - 1]!.dphi_deg) > 180) {
// null 线
dataPhase.push(null);
}
rawIndexToChartIndex[i] = dataPhase.length;
chartIndexToRawIndex[dataPhase.length] = i;
dataPhase.push([x, y]);
}
const option = {
animation: false,
@ -112,6 +124,7 @@ const updateChart = () => {
smooth: 0.3,
symbol: 'circle',
symbolSize: 4,
connectNulls: false,
itemStyle: { color: '#c23531' },
lineStyle: { color: '#c23531', width: 2 }
},
@ -134,10 +147,12 @@ const updateChart = () => {
const showTip = (index: number) => {
if (!chartInstance) return;
const chartDataIndex = rawIndexToChartIndex[index];
if (chartDataIndex === undefined) return;
chartInstance.dispatchAction({
type: 'showTip',
seriesIndex: 0,
dataIndex: index
dataIndex: chartDataIndex
});
};

View File

@ -3,13 +3,16 @@
class="window"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
v-if="modelValue"
ref="windowEl"
>
<div
class="title-bar"
@pointerdown="startDrag"
>
<div class="title-text">{{ title }}</div>
<Button class="close-btn" @click="close">×</Button>
<Button class="close-btn" @click="close">
<X color="black" />
</Button>
</div>
<div class="window-body">
<slot></slot>
@ -18,8 +21,9 @@
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from 'vue';
import { reactive, onMounted, onUnmounted, ref } from 'vue';
import Button from './Button.vue';
import { X } from 'lucide-vue-next'
const props = withDefaults(
defineProps<{
@ -35,6 +39,8 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const windowEl = ref<HTMLElement | null>(null);
const position = reactive({ x: 0, y: 0 });
const dragState = reactive({
isDragging: false,
@ -42,12 +48,64 @@ const dragState = reactive({
startY: 0,
initialX: 0,
initialY: 0,
invLinearMatrix: null as DOMMatrix | null,
});
const getLinearTransformMatrix = (el: HTMLElement): DOMMatrix => {
const style = window.getComputedStyle(el);
const t = style.transform;
const matrix = t && t !== 'none' ? new DOMMatrix(t) : new DOMMatrix();
// 线//
matrix.m41 = 0;
matrix.m42 = 0;
matrix.m43 = 0;
return matrix;
};
const findTransformedAncestor = (el: HTMLElement | null): HTMLElement | null => {
let cur = el?.parentElement ?? null;
while (cur) {
const style = window.getComputedStyle(cur);
if (
(style.transform && style.transform !== 'none') ||
(style.perspective && style.perspective !== 'none') ||
(style.filter && style.filter !== 'none')
) {
return cur;
}
cur = cur.parentElement;
}
return null;
};
const getCumulativeLinearMatrixToViewport = (fromEl: HTMLElement | null): DOMMatrix => {
// fromEl transform线 -> 线
let cur = fromEl;
let combined = new DOMMatrix();
while (cur) {
const t = getLinearTransformMatrix(cur);
combined = t.multiply(combined);
cur = cur.parentElement;
}
return combined;
};
const screenDeltaToLocalDelta = (invLinearMatrix: DOMMatrix | null, dx: number, dy: number) => {
if (!invLinearMatrix) return { x: dx, y: dy };
//
const p0 = new DOMPoint(0, 0);
const p1 = new DOMPoint(dx, dy);
const lp0 = p0.matrixTransform(invLinearMatrix);
const lp1 = p1.matrixTransform(invLinearMatrix);
return { x: lp1.x - lp0.x, y: lp1.y - lp0.y };
};
onMounted(() => {
//
position.x = (window.innerWidth - 300) / 2;
position.y = (window.innerHeight - 250) / 2;
// 使 rotate
const container = findTransformedAncestor(windowEl.value) ?? windowEl.value?.parentElement;
const w = container?.clientWidth ?? window.innerWidth;
position.x = (w - 400) / 2;
position.y = 50;
});
const startDrag = (e: PointerEvent) => {
@ -58,6 +116,15 @@ const startDrag = (e: PointerEvent) => {
dragState.initialX = position.x;
dragState.initialY = position.y;
// rotate/scale transform
const container = findTransformedAncestor(windowEl.value);
const toViewport = getCumulativeLinearMatrixToViewport(container);
try {
dragState.invLinearMatrix = toViewport.inverse();
} catch {
dragState.invLinearMatrix = null;
}
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
document.addEventListener('pointermove', onDrag);
document.addEventListener('pointerup', stopDrag);
@ -69,12 +136,14 @@ const onDrag = (e: PointerEvent) => {
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
position.x = dragState.initialX + deltaX;
position.y = dragState.initialY + deltaY;
const localDelta = screenDeltaToLocalDelta(dragState.invLinearMatrix, deltaX, deltaY);
position.x = dragState.initialX + localDelta.x;
position.y = dragState.initialY + localDelta.y;
};
const stopDrag = () => {
dragState.isDragging = false;
dragState.invLinearMatrix = null;
document.removeEventListener('pointermove', onDrag);
document.removeEventListener('pointerup', stopDrag);
};
@ -108,6 +177,7 @@ onUnmounted(() => {
cursor: move;
user-select: none;
height: 32px;
touch-action: none;
}
.title-text {
@ -123,8 +193,8 @@ onUnmounted(() => {
.window-body {
padding: 12px;
background-color: rgb(221, 221, 221);
border-top: 1px solid #808080;
background-color: rgb(226, 226, 226);
border-top: 1px solid #c8c8c8;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -1,9 +1,25 @@
import { createApp } from 'vue'
import './style.css'
import './assets/stylesheet.css'
import 'katex/dist/katex.min.css'
import App from './App.vue'
function resize() {
const app = document.getElementById('app');
if (!app) return;
let { innerWidth: w, innerHeight: h } = window;
let isRotate = false;
if (h > w) { [w, h] = [h, w]; isRotate = true; };
let scaleRatio = 1
if (h < 500) { scaleRatio = h / 500; h = 500; w = w / scaleRatio; }
app.style.width = w + 'px';
app.style.height = h + 'px';
app.style.transform = isRotate ? `translateX(${h * scaleRatio}px) rotate(90deg) scale(${scaleRatio})` : 'none';
}
resize();
window.addEventListener('resize', resize);
createApp(App).mount('#app')
document.body.addEventListener('contextmenu', event => event.preventDefault());
// alert(window.innerWidth + ", " + window.innerHeight);

View File

@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue';
import * as api from '../api.ts';
import type { AppState, BatchTask } from '../types.ts';
import Panel from '../components/Panel.vue';
import Button from '../components/Button.vue';
import Machine from '../components/Machine.vue';
import ManualAdjust from '../components/ManualAdjust.vue';
import Lissajous from '../components/Lissajous.vue';
import Window from '../components/Window.vue';
import Numpad from '../components/Numpad.vue';
import { showMessage } from '../utils/message';
defineProps<{
state: any;
state: AppState;
}>();
const controlStart = ref('10');
@ -46,6 +48,8 @@ function confirmInput() {
const emit = defineEmits<{
(e: 'start'): void;
(e: 'showManualAdjust'): void;
(e: 'showMotorSpeedSetting'): void;
}>();
function startBatch(currentDis: number) {
@ -64,7 +68,7 @@ function startBatch(currentDis: number) {
emit('start');
const tasks = [];
const tasks: BatchTask[] = [];
const currentSteps = currentDis;
const startSteps = start * 1600;
const diff = startSteps - currentSteps;
@ -83,13 +87,13 @@ function startBatch(currentDis: number) {
const count = Math.floor(Math.abs(totalDis) / step);
if (count > 0) {
const dir = totalDis >= 0 ? 1 : -1;
const stepSteps = step * 1600 * dir;
tasks.push({
cmd: 'move_measure',
args: { steps: stepSteps },
repeat: count
});
const dir = totalDis >= 0 ? 1 : -1;
const stepSteps = step * 1600 * dir;
tasks.push({
cmd: 'move_measure',
args: { steps: stepSteps },
repeat: count
});
}
api.batch(tasks).then(() => {
@ -100,76 +104,87 @@ function startBatch(currentDis: number) {
<template>
<div class="control-page-content">
<div class="left-section">
<!-- 顶部装置位置示意图 -->
<Panel class="machine-panel-wrapper">
<Machine :dis="state.dis"
:task="state.tasks.reduce((acc: any, task: any) => (task.type == 'move' ? ({ remaining_steps: acc.remaining_steps + task.remaining_steps }) : acc), { remaining_steps: 0 })" />
<div class="left-section">
<!-- 顶部装置位置示意图 -->
<Panel class="machine-panel-wrapper">
<Machine :dis="state.dis" :task="state.tasks.find(t => t.type == 'move')" />
<ManualAdjust @show-input="emit('showManualAdjust')" class="manual-adjust-wrapper" />
</Panel>
<!-- 下方李萨如 + 控制面板 -->
<div class="bottom-row">
<Panel class="oscilloscope-panel-wrapper">
<div class="chart-container">
<Lissajous :data="state.last_measurement" />
</div>
</Panel>
<!-- 下方李萨如 + 控制面板 -->
<div class="bottom-row">
<Panel class="oscilloscope-panel-wrapper">
<div class="chart-container">
<Lissajous :data="state.last_measurement" />
</div>
</Panel>
<Panel class="inputs-panel-wrapper">
<div class="input-container">
<div class="input-group">
<label>起点</label>
<div class="input-wrapper" @click="openInput('start')">
<div class="input-display">{{ controlStart || '10' }}</div>
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>终点</label>
<div class="input-wrapper" @click="openInput('end')">
<div class="input-display">{{ controlEnd || '30' }}</div>
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>步进</label>
<div class="input-wrapper" @click="openInput('step')">
<div class="input-display">{{ controlStep || '0.1' }}</div>
<span class="unit">mm</span>
</div>
</div>
<Button @click="startBatch(state.dis)" bg="limegreen" class="start-btn">开始任务</Button>
</div>
</Panel>
</div>
</div>
<div class="right-section">
<Panel class="task-list-panel">
<div class="task-head">任务列表 ({{ state.total_tasks }})</div>
<div class="task-list">
<div v-for="task in state.tasks.slice(-5)" :key="task.id" class="task-item">
<div class="task-info">
<span class="type" :class="task.type">{{ task.type === 'move' ? '移动' : '测量' }}</span>
<span class="status" :class="task.status">{{ task.status }}</span>
</div>
<div class="task-detail" v-if="task.type === 'move'">
{{ (task.steps / 1600).toFixed(1) }}mm
<Panel class="inputs-panel-wrapper">
<div class="input-container">
<div class="input-group">
<label>起点</label>
<div class="input-wrapper">
<Button class="input-display" @click="openInput('start')">{{ controlStart || '10' }}</Button>
<span class="unit">mm</span>
</div>
</div>
<div v-if="state.tasks.length === 0" class="empty-tasks">暂无任务</div>
<div class="input-group">
<label>终点</label>
<div class="input-wrapper">
<Button class="input-display" @click="openInput('end')">{{ controlEnd || '30' }}</Button>
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>步进</label>
<div class="input-wrapper">
<Button class="input-display" @click="openInput('step')">{{ controlStep || '0.1' }}</Button>
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>电机速度</label>
<div class="input-wrapper">
<Button class="input-display" @click="emit('showMotorSpeedSetting')">{{ state.speed ? Math.round(state.speed / 1.6) : '--'
}}</Button>
<span class="unit">μm/s</span>
</div>
</div>
<Button @click="startBatch(state.dis)" bg="limegreen" class="start-btn">开始任务</Button>
</div>
</Panel>
</div>
</div>
<Window v-model="showNumpad" :title="numpadTitle">
<div class="numpad-content">
<Numpad v-model="numpadValue" :label="numpadTitle" unit="mm" />
<div class="numpad-actions">
<Button class="action-btn" @click="showNumpad = false">取消</Button>
<Button class="action-btn" bg="limegreen" @click="confirmInput">确定</Button>
<div class="right-section">
<Panel class="task-list-panel">
<div class="task-head">任务列表 ({{ state.total_tasks }})</div>
<div class="task-list">
<div v-for="task in state.tasks.slice(-5)" :key="task.id" class="task-item">
<div class="task-info">
<span class="type" :class="task.type">{{ task.type === 'move' ? '移动' : '测量' }}</span>
<span class="status" :class="task.status">{{ task.status }}</span>
</div>
<div class="task-detail" v-if="task.type === 'move'">
{{ (task.steps / 1600).toFixed(3) }}mm
</div>
</div>
<div v-if="state.tasks.length === 0" class="empty-tasks">暂无任务</div>
</div>
</Window>
<Button class="action-btn" @click="api.stopAll()" bg="red">停止</Button>
</Panel>
</div>
<Window v-model="showNumpad" :title="numpadTitle">
<div class="numpad-content">
<Numpad v-model="numpadValue" :label="numpadTitle" unit="mm" />
<div class="numpad-actions">
<Button class="action-btn" @click="showNumpad = false">取消</Button>
<Button class="action-btn" bg="limegreen" @click="confirmInput">确定</Button>
</div>
</div>
</Window>
</div>
</template>
@ -185,219 +200,217 @@ function startBatch(currentDis: number) {
}
.left-section {
flex: 3;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
min-width: 0; // Prevent flex overflow
flex: 3;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
min-width: 0; // Prevent flex overflow
}
.right-section {
flex: 1.5;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
flex: 1.5;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
.machine-panel-wrapper {
flex: 0 0 auto; //
padding: 10px;
flex: 0 0 auto; //
padding: 10px;
display: flex;
}
.manual-adjust-wrapper {
width: 220px;
}
.bottom-row {
flex: 1;
display: flex;
gap: 8px;
min-height: 0; // Fix nested flex overflow
flex: 1;
display: flex;
gap: 8px;
min-height: 0; // Fix nested flex overflow
}
.oscilloscope-panel-wrapper {
flex: 3; //
overflow: hidden;
position: relative;
/* ensure inner absolute positioning works if needed, usually oscilloscope uses flex */
display: flex;
flex-direction: column;
padding: 4px;
flex: 3; //
overflow: hidden;
position: relative;
/* ensure inner absolute positioning works if needed, usually oscilloscope uses flex */
display: flex;
flex-direction: column;
padding: 4px;
}
.chart-title {
font-weight: 600;
font-size: 16px;
color: #334155;
margin-bottom: 8px;
font-weight: 600;
font-size: 16px;
color: #334155;
margin-bottom: 8px;
}
.chart-container {
flex: 1;
min-height: 200px;
width: 100%;
flex: 1;
min-height: 200px;
width: 100%;
}
.inputs-panel-wrapper {
flex: 2; //
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
flex: 2.5; //
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
}
.input-container {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.input-group {
display: flex;
align-items: center;
justify-content: space-between;
label {
font-weight: bold;
font-size: 16px;
color: #333;
width: 64px;
}
.input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
label {
font-weight: bold;
font-size: 16px;
color: #333;
width: 50px;
}
.input-display {
width: 80px;
height: 40px;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.input-display {
width: 80px;
height: 40px;
border: 1px solid #ccc;
border-radius: 4px;
text-align: center;
font-size: 18px;
background: white;
line-height: 40px;
cursor: pointer;
}
.unit {
font-size: 14px;
color: #666;
width: 30px;
}
.unit {
font-size: 14px;
color: #666;
width: 30px;
}
}
.start-btn {
margin-top: 10px;
height: 48px;
width: 100%;
font-size: 18px;
border-radius: 6px;
margin-top: 4px;
height: 36px;
width: 100%;
font-size: 18px;
border-radius: 6px;
}
.numpad-content {
display: flex;
gap: 16px;
width: 100%;
display: flex;
gap: 16px;
width: 100%;
}
.numpad-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
min-width: 120px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
min-width: 120px;
}
.action-btn {
width: 100%;
height: 52px;
font-size: 16px;
border-radius: 6px;
width: 100%;
height: 52px;
font-size: 16px;
border-radius: 6px;
}
.task-list-panel {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px;
height: 100%;
overflow: hidden;
.task-head {
font-weight: bold;
margin-bottom: 8px;
font-size: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 6px;
}
.task-list {
flex: 1;
overflow-y: hidden;
display: flex;
flex-direction: column;
padding: 10px;
height: 100%;
overflow: hidden;
gap: 6px;
}
.task-head {
font-weight: bold;
margin-bottom: 8px;
font-size: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 6px;
}
.task-item {
background: #f8fafc;
padding: 8px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid #cbd5e1;
.task-list {
flex: 1;
overflow-y: hidden;
.task-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-item {
background: #f8fafc;
padding: 8px;
border-radius: 4px;
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
border-left: 4px solid #cbd5e1;
}
.task-info {
display: flex;
gap: 8px;
align-items: center;
.type {
font-weight: bold;
font-size: 14px;
&.move {
color: #3b82f6;
}
.type {
font-weight: bold;
font-size: 14px;
&.measure {
color: #8b5cf6;
}
}
&.move {
color: #3b82f6;
}
.status {
font-size: 12px;
padding: 2px 6px;
border-radius: 999px;
background: #e2e8f0;
&.measure {
color: #8b5cf6;
}
&.running {
background: #dbface;
color: #166534;
border: 1px solid #166534;
}
.status {
font-size: 12px;
padding: 2px 6px;
border-radius: 999px;
&.pending {
background: #fef9c3;
color: #854d0e;
}
&.queued {
background: #e2e8f0;
&.running {
background: #dbface;
color: #166534;
border: 1px solid #166534;
}
&.pending {
background: #fef9c3;
color: #854d0e;
}
&.queued {
background: #e2e8f0;
color: #475569;
}
color: #475569;
}
}
}
.empty-tasks {
text-align: center;
color: #999;
margin-top: 20px;
}
.empty-tasks {
text-align: center;
color: #999;
margin-top: 20px;
}
}
</style>

1006
src/pages/DataProcess.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed, nextTick } from 'vue';
import type { HistoryItem } from '../types.ts';
import Panel from '../components/Panel.vue';
import Button from '../components/Button.vue';
import Lissajous from '../components/Lissajous.vue';
import WaveformChart from '../components/WaveformChart.vue';
import TrendChart from '../components/TrendChart.vue';
interface HistoryItem {
ts: number;
currentDis: number;
dphi_deg: number; // Phase
amp2_pp_adc: number; // P2P
f0_hz: number;
[key: string]: any;
}
import { ArrowLeft, ArrowRight } from 'lucide-vue-next';
const props = defineProps<{
history: HistoryItem[];
@ -52,22 +45,14 @@ const next = () => {
}
};
const onSelectPoint = (index: number) => {
selectedIndex.value = index;
updateHighlight();
};
const updateHighlight = () => {
trendChartRef.value?.showTip(selectedIndex.value);
};
watch(() => props.history, () => {
nextTick(() => {
// Auto select last point if none selected or just appended
if (props.history.length > 0 && (selectedIndex.value === -1 || selectedIndex.value === props.history.length - 2)) {
selectedIndex.value = props.history.length - 1;
updateHighlight();
}
selectedIndex.value = props.history.length - 1;
updateHighlight();
});
}, { deep: true });
@ -77,7 +62,7 @@ watch(() => props.history, () => {
<div class="history-page">
<div class="charts-section">
<Panel class="trend-chart-panel">
<TrendChart ref="trendChartRef" :data="history" @select="onSelectPoint" />
<TrendChart ref="trendChartRef" :data="history" />
</Panel>
</div>
<div class="detail-section">
@ -113,9 +98,13 @@ watch(() => props.history, () => {
<div class="controls">
<div class="nav-btns">
<Button @long-press="prev" :long-press-interval="100" :disabled="selectedIndex <= 0"
class="nav-btn"></Button>
class="nav-btn">
<ArrowLeft />
</Button>
<Button @long-press="next" :long-press-interval="100"
:disabled="selectedIndex >= history.length - 1" class="nav-btn"></Button>
:disabled="selectedIndex >= history.length - 1" class="nav-btn">
<ArrowRight />
</Button>
</div>
<Button bg="red" @click="$emit('clear')" class="clear-btn">清除历史</Button>
</div>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import * as api from '../api.ts';
import type { AppState, HistoryItem } from '../types.ts';
import Button from '../components/Button.vue';
import Panel from '../components/Panel.vue';
import Machine from '../components/Machine.vue';
import ManualAdjust from '../components/ManualAdjust.vue';
import WaveformChart from '../components/WaveformChart.vue';
import { RefreshCcw } from 'lucide-vue-next';
defineProps<{
state: AppState;
historyData: HistoryItem[];
}>();
const emit = defineEmits<{
(e: 'showManualAdjust'): void;
(e: 'changePage', page: 2): void;
}>();
</script>
<template>
<div class="page-home-content">
<div class="left">
<Panel style="flex: 1;">
<WaveformChart :data="state.last_measurement" />
<Button @click="api.measure()" style="position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;">
<RefreshCcw />
</Button>
</Panel>
<Panel>
<Machine :dis="state.dis" :task="state.tasks.find(t => t.type == 'move')" />
</Panel>
</div>
<div class="right">
<div class="label">相位差</div>
<div class="data">{{ state.phase.toFixed(4) }} rad</div>
<div class="label">频率</div>
<div class="data">{{ state.freq }} KHz</div>
<div class="label">峰峰值</div>
<div class="data">{{ state.p2p }} ADC</div>
<div class="action">
<ManualAdjust @show-input="emit('showManualAdjust')" />
<div class="right">
<Button class="action-btn" @click="emit('changePage', 2)" bg="limegreen">开始实验</Button>
<Button class="action-btn" @click="api.stopAll()" bg="red">停止</Button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.page-home-content {
display: flex;
height: 100%;
width: 100%;
}
.left,
.right {
display: flex;
flex-direction: column;
padding: 10px;
}
.left {
flex: 3;
position: relative;
align-items: stretch;
gap: 8px;
}
.right {
flex: 2;
position: relative;
align-items: center;
.label {
align-self: flex-start;
font-size: 24px;
}
.label::after {
content: '';
margin-left: 4px;
}
.data {
font-size: 32px;
font-weight: bold;
margin-bottom: 12px;
margin-top: -6px;
}
}
.action {
display: flex;
justify-content: space-between;
width: 100%;
.right {
display: flex;
flex-direction: column;
gap: 6px;
height: 150px;
justify-content: center;
}
.action-btn {
width: 140px;
height: 52px;
border-radius: 8px;
font-size: 20px;
}
}
</style>

28
src/pages/Measure.vue Normal file
View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import * as api from '../api.ts';
import type { AppState } from '../types.ts';
import Button from '../components/Button.vue';
import Oscilloscope from '../components/Oscilloscope.vue';
defineProps<{
state: AppState;
}>();
</script>
<template>
<div class="page-measure-content">
<Oscilloscope :data="state.last_measurement">
<template #controls>
<Button @click="api.measure()" bg="limegreen"
style="height: 52px; width: 100px; border-radius: 8px;">单次测量</Button>
</template>
</Oscilloscope>
</div>
</template>
<style scoped lang="scss">
.page-measure-content {
width: 100%;
height: 100%;
}
</style>

View File

@ -1,15 +1,26 @@
body {
margin: 0;
height: 100vh;
height: 100dvh;
width: 100vw;
}
* {
-webkit-touch-callout: none !important;
/* 禁用链接/图片长按菜单 */
-webkit-user-select: none !important;
/* 禁止文本选择(最关键) */
box-sizing: border-box;
touch-action: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
font-family: 'PingFang SC', sans-serif;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif
}
.panel-title{
font-size: 20px;
.panel-title {
font-size: 20px;
}
#app {
min-height: 500px;
transform-origin: 0 0;
}

61
src/types.ts Normal file
View File

@ -0,0 +1,61 @@
// 测量数据类型
export interface MeasurementData {
ts: number;
idn: null;
points_mode: 'NORM';
n: number;
tscale: number;
toffs: number;
f0_hz: number;
amp1_pp_adc: number;
amp2_pp_adc: number;
phi1_rad: number;
phi2_rad: number;
dphi_rad: number;
dphi_deg: number;
dt_s: number;
wave1: number[];
wave2: number[];
}
// 任务类型
export type Task = {
id: string;
type: 'move';
steps: number;
remaining_steps: number;
status: 'running' | 'pending' | 'queued';
created_at: number;
} | {
id: string;
type: 'measure';
status: 'running' | 'pending' | 'queued';
created_at: number;
};
// 应用状态类型
export interface AppState {
connected: boolean;
dis: number;
phase: number;
freq: number;
p2p: number;
speed: number;
total_tasks: number;
tasks: Task[];
last_measurement: MeasurementData;
}
// 历史记录项类型
export interface HistoryItem extends MeasurementData {
currentDis: number;
}
// 批量任务类型
export interface BatchTask {
cmd: 'move' | 'move_measure';
args: {
steps: number;
};
repeat: number;
}

View File

@ -5,7 +5,9 @@ import { AlertTriangle, XOctagon, Info, CheckCircle2 } from 'lucide-vue-next';
export function showMessage(message: string, type: 'info' | 'error' | 'success' | 'warning' = 'info', title?: string) {
const container = document.createElement('div');
document.body.appendChild(container);
const app = document.getElementById('app');
if (!app) return;
app.appendChild(container);
const destroy = () => {
render(null, container);

View File

@ -4,4 +4,14 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})