feat(shortcut): add shortcut keys, screenshots and voice, video calls

This commit is contained in:
Dawn
2025-08-18 21:08:35 +08:00
committed by GitHub
parent ac3dac35a3
commit 3ab5b0bdbb
51 changed files with 3064 additions and 844 deletions

49
.gitattributes vendored
View File

@@ -25,32 +25,39 @@
# 代码统计设置 - 只统计 Vue、TypeScript 和 Rust 文件
# 排除 JavaScript 文件
"*.js" linguist-vendored
"*.jsx" linguist-vendored
"*.cjs" linguist-vendored
"*.mjs" linguist-vendored
*.js linguist-vendored
*.jsx linguist-vendored
*.cjs linguist-vendored
*.mjs linguist-vendored
# 排除样式文件
"*.css" linguist-vendored
"*.scss" linguist-vendored
"*.sass" linguist-vendored
"*.less" linguist-vendored
"*.styl" linguist-vendored
*.css linguist-vendored
*.scss linguist-vendored
*.sass linguist-vendored
*.less linguist-vendored
*.styl linguist-vendored
# 排除配置和文档文件
"*.json" linguist-vendored
"*.html" linguist-vendored
"*.md" linguist-documentation
"*.yml" linguist-vendored
"*.yaml" linguist-vendored
*.json linguist-vendored
*.html linguist-vendored
*.md linguist-documentation
*.yml linguist-vendored
*.yaml linguist-vendored
# 排除其他语言文件
"*.kt" linguist-vendored
"*.java" linguist-vendored
"*.py" linguist-vendored
*.kt linguist-vendored
*.java linguist-vendored
*.py linguist-vendored
# 确保构建目录和依赖目录不被统计
"**/target/**" linguist-vendored
"/node_modules/**" linguist-vendored
"/scripts/**" linguist-vendored
"/build/**" linguist-vendored
**/target/** linguist-vendored
**/node_modules/** linguist-vendored
scripts/** linguist-vendored
**/build/** linguist-vendored
**/dist/** linguist-vendored
# 确保主要代码文件被正确识别
*.vue linguist-detectable
*.ts linguist-detectable
*.tsx linguist-detectable
*.rs linguist-detectable

13
biome.json vendored
View File

@@ -5,12 +5,12 @@
"ignoreUnknown": false,
"includes": [
"**/*.{js,jsx,ts,tsx,vue,json}",
"!.*/**",
"!public/**",
"!.*",
"!public/*",
"!src/typings/*.d.ts",
"!src-tauri/**",
"!node_modules/**",
"!dist/**"
"!src-tauri/*",
"!node_modules/*",
"!dist/*"
]
},
"formatter": {
@@ -58,7 +58,8 @@
"noAssignInExpressions": "off",
"noImplicitAnyLet": "off",
"noConfusingVoidType": "off",
"noArrayIndexKey": "off"
"noArrayIndexKey": "off",
"useIterableCallbackReturn": "off"
}
}
},

6
package.json vendored
View File

@@ -77,6 +77,7 @@
"@tauri-apps/plugin-clipboard-manager": "2.3.0",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-fs": "^2.4.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
"@tauri-apps/plugin-http": "2.5.1",
"@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.0",
@@ -113,10 +114,9 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@biomejs/biome": "^2.1.4",
"@biomejs/biome": "^2.2.0",
"@commitlint/cli": "^19.6.0",
"@commitlint/config-conventional": "^19.6.0",
"@iconify/vue": "^5.0.0",
"@release-it/bumper": "^6.0.1",
"@release-it/conventional-changelog": "8.0.2",
"@rollup/plugin-terser": "^0.4.4",
@@ -147,7 +147,7 @@
"typescript": "^5.9.2",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "7.1.1",
"vite": "7.1.2",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.5"

424
pnpm-lock.yaml generated vendored
View File

@@ -32,6 +32,9 @@ importers:
'@tauri-apps/plugin-fs':
specifier: ^2.4.1
version: 2.4.1
'@tauri-apps/plugin-global-shortcut':
specifier: ^2.3.0
version: 2.3.0
'@tauri-apps/plugin-http':
specifier: 2.5.1
version: 2.5.1
@@ -136,17 +139,14 @@ importers:
version: 4.5.1(vue@3.5.18(typescript@5.9.2))
devDependencies:
'@biomejs/biome':
specifier: ^2.1.4
version: 2.1.4
specifier: ^2.2.0
version: 2.2.0
'@commitlint/cli':
specifier: ^19.6.0
version: 19.6.1(@types/node@24.0.10)(typescript@5.9.2)
'@commitlint/config-conventional':
specifier: ^19.6.0
version: 19.6.0
'@iconify/vue':
specifier: ^5.0.0
version: 5.0.0(vue@3.5.18(typescript@5.9.2))
'@release-it/bumper':
specifier: ^6.0.1
version: 6.0.1(release-it@17.11.0(typescript@5.9.2))
@@ -182,13 +182,13 @@ importers:
version: 66.4.2
'@unocss/vite':
specifier: ^66.4.2
version: 66.4.2(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
version: 66.4.2(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.1(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))
version: 6.0.1(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))
'@vitejs/plugin-vue-jsx':
specifier: ^5.0.1
version: 5.0.1(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))
version: 5.0.1(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4)
@@ -238,11 +238,11 @@ importers:
specifier: ^28.8.0
version: 28.8.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.18(typescript@5.9.2))
vite:
specifier: 7.1.1
version: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
specifier: 7.1.2
version: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite-plugin-vue-setup-extend:
specifier: ^0.4.0
version: 0.4.0(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
version: 0.4.0(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.0.10)(@vitest/ui@3.2.4)(happy-dom@15.11.7)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
@@ -417,59 +417,59 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.1.4':
resolution: {integrity: sha512-QWlrqyxsU0FCebuMnkvBIkxvPqH89afiJzjMl+z67ybutse590jgeaFdDurE9XYtzpjRGTI1tlUZPGWmbKsElA==}
'@biomejs/biome@2.2.0':
resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.1.4':
resolution: {integrity: sha512-sCrNENE74I9MV090Wq/9Dg7EhPudx3+5OiSoQOkIe3DLPzFARuL1dOwCWhKCpA3I5RHmbrsbNSRfZwCabwd8Qg==}
'@biomejs/cli-darwin-arm64@2.2.0':
resolution: {integrity: sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.1.4':
resolution: {integrity: sha512-gOEICJbTCy6iruBywBDcG4X5rHMbqCPs3clh3UQ+hRKlgvJTk4NHWQAyHOXvaLe+AxD1/TNX1jbZeffBJzcrOw==}
'@biomejs/cli-darwin-x64@2.2.0':
resolution: {integrity: sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.1.4':
resolution: {integrity: sha512-nYr7H0CyAJPaLupFE2cH16KZmRC5Z9PEftiA2vWxk+CsFkPZQ6dBRdcC6RuS+zJlPc/JOd8xw3uCCt9Pv41WvQ==}
'@biomejs/cli-linux-arm64-musl@2.2.0':
resolution: {integrity: sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.1.4':
resolution: {integrity: sha512-juhEkdkKR4nbUi5k/KRp1ocGPNWLgFRD4NrHZSveYrD6i98pyvuzmS9yFYgOZa5JhaVqo0HPnci0+YuzSwT2fw==}
'@biomejs/cli-linux-arm64@2.2.0':
resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.1.4':
resolution: {integrity: sha512-lvwvb2SQQHctHUKvBKptR6PLFCM7JfRjpCCrDaTmvB7EeZ5/dQJPhTYBf36BE/B4CRWR2ZiBLRYhK7hhXBCZAg==}
'@biomejs/cli-linux-x64-musl@2.2.0':
resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.1.4':
resolution: {integrity: sha512-Eoy9ycbhpJVYuR+LskV9s3uyaIkp89+qqgqhGQsWnp/I02Uqg2fXFblHJOpGZR8AxdB9ADy87oFVxn9MpFKUrw==}
'@biomejs/cli-linux-x64@2.2.0':
resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.1.4':
resolution: {integrity: sha512-3WRYte7orvyi6TRfIZkDN9Jzoogbv+gSvR+b9VOXUg1We1XrjBg6WljADeVEaKTvOcpVdH0a90TwyOQ6ue4fGw==}
'@biomejs/cli-win32-arm64@2.2.0':
resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.1.4':
resolution: {integrity: sha512-tBc+W7anBPSFXGAoQW+f/+svkpt8/uXfRwDzN1DvnatkRMt16KIYpEi/iw8u9GahJlFv98kgHcIrSsZHZTR0sw==}
'@biomejs/cli-win32-x64@2.2.0':
resolution: {integrity: sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -580,8 +580,8 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.25.8':
resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
@@ -592,8 +592,8 @@ packages:
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.8':
resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==}
'@esbuild/android-arm64@0.25.9':
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
@@ -604,8 +604,8 @@ packages:
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.8':
resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==}
'@esbuild/android-arm@0.25.9':
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
@@ -616,8 +616,8 @@ packages:
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.8':
resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==}
'@esbuild/android-x64@0.25.9':
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
@@ -628,8 +628,8 @@ packages:
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.8':
resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==}
'@esbuild/darwin-arm64@0.25.9':
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
@@ -640,8 +640,8 @@ packages:
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.8':
resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==}
'@esbuild/darwin-x64@0.25.9':
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
@@ -652,8 +652,8 @@ packages:
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.8':
resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==}
'@esbuild/freebsd-arm64@0.25.9':
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
@@ -664,8 +664,8 @@ packages:
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.8':
resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==}
'@esbuild/freebsd-x64@0.25.9':
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
@@ -676,8 +676,8 @@ packages:
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.8':
resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==}
'@esbuild/linux-arm64@0.25.9':
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
@@ -688,8 +688,8 @@ packages:
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.8':
resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==}
'@esbuild/linux-arm@0.25.9':
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
@@ -700,8 +700,8 @@ packages:
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.8':
resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==}
'@esbuild/linux-ia32@0.25.9':
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
@@ -712,8 +712,8 @@ packages:
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.8':
resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==}
'@esbuild/linux-loong64@0.25.9':
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
@@ -724,8 +724,8 @@ packages:
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.8':
resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==}
'@esbuild/linux-mips64el@0.25.9':
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
@@ -736,8 +736,8 @@ packages:
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.8':
resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==}
'@esbuild/linux-ppc64@0.25.9':
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
@@ -748,8 +748,8 @@ packages:
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.8':
resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==}
'@esbuild/linux-riscv64@0.25.9':
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
@@ -760,8 +760,8 @@ packages:
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.8':
resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==}
'@esbuild/linux-s390x@0.25.9':
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
@@ -772,14 +772,14 @@ packages:
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.8':
resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==}
'@esbuild/linux-x64@0.25.9':
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.8':
resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==}
'@esbuild/netbsd-arm64@0.25.9':
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
@@ -790,8 +790,8 @@ packages:
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.8':
resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==}
'@esbuild/netbsd-x64@0.25.9':
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
@@ -802,8 +802,8 @@ packages:
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.25.8':
resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==}
'@esbuild/openbsd-arm64@0.25.9':
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
@@ -814,14 +814,14 @@ packages:
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.8':
resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==}
'@esbuild/openbsd-x64@0.25.9':
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.8':
resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==}
'@esbuild/openharmony-arm64@0.25.9':
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
@@ -832,8 +832,8 @@ packages:
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.8':
resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==}
'@esbuild/sunos-x64@0.25.9':
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
@@ -844,8 +844,8 @@ packages:
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.8':
resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==}
'@esbuild/win32-arm64@0.25.9':
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
@@ -856,8 +856,8 @@ packages:
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.8':
resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==}
'@esbuild/win32-ia32@0.25.9':
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
@@ -868,8 +868,8 @@ packages:
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.8':
resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==}
'@esbuild/win32-x64@0.25.9':
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -888,14 +888,6 @@ packages:
'@iarna/toml@2.2.5':
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/vue@5.0.0':
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
peerDependencies:
vue: '>=3'
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1028,10 +1020,16 @@ packages:
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -1049,12 +1047,18 @@ packages:
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
@@ -1499,6 +1503,9 @@ packages:
'@tauri-apps/plugin-fs@2.4.1':
resolution: {integrity: sha512-vJlKZVGF3UAFGoIEVT6Oq5L4HGDCD78WmA4uhzitToqYiBKWAvZR61M6zAyQzHqLs0ADemkE4RSy/5sCmZm6ZQ==}
'@tauri-apps/plugin-global-shortcut@2.3.0':
resolution: {integrity: sha512-WbAz0ElhpP+0kzQZRScdCC7UQ7OPH8PAn//fsBNu7+ywihsnVSVOg1L9YhieAtLNtAlnmFI69Yl5AGaA3ge5IQ==}
'@tauri-apps/plugin-http@2.5.1':
resolution: {integrity: sha512-SpQ1azXEdQI0UB2NZTIPljJTDEe0bIaKzHYR/k4UQp6yzRYGLC/ktmIgEfQ2RvKAWus8GcYgGr5K6LJPbo/NZw==}
@@ -2448,8 +2455,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
esbuild@0.25.8:
resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==}
esbuild@0.25.9:
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
engines: {node: '>=18'}
hasBin: true
@@ -2550,6 +2557,15 @@ packages:
picomatch:
optional: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -4108,6 +4124,10 @@ packages:
resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==}
engines: {node: '>=18.12.0'}
unplugin@2.3.6:
resolution: {integrity: sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg==}
engines: {node: '>=18.12.0'}
untyped@2.0.0:
resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==}
hasBin: true
@@ -4147,8 +4167,8 @@ packages:
peerDependencies:
vite: '>=2.0.0'
vite@7.1.1:
resolution: {integrity: sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==}
vite@7.1.2:
resolution: {integrity: sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -4587,39 +4607,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.1.4':
'@biomejs/biome@2.2.0':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.1.4
'@biomejs/cli-darwin-x64': 2.1.4
'@biomejs/cli-linux-arm64': 2.1.4
'@biomejs/cli-linux-arm64-musl': 2.1.4
'@biomejs/cli-linux-x64': 2.1.4
'@biomejs/cli-linux-x64-musl': 2.1.4
'@biomejs/cli-win32-arm64': 2.1.4
'@biomejs/cli-win32-x64': 2.1.4
'@biomejs/cli-darwin-arm64': 2.2.0
'@biomejs/cli-darwin-x64': 2.2.0
'@biomejs/cli-linux-arm64': 2.2.0
'@biomejs/cli-linux-arm64-musl': 2.2.0
'@biomejs/cli-linux-x64': 2.2.0
'@biomejs/cli-linux-x64-musl': 2.2.0
'@biomejs/cli-win32-arm64': 2.2.0
'@biomejs/cli-win32-x64': 2.2.0
'@biomejs/cli-darwin-arm64@2.1.4':
'@biomejs/cli-darwin-arm64@2.2.0':
optional: true
'@biomejs/cli-darwin-x64@2.1.4':
'@biomejs/cli-darwin-x64@2.2.0':
optional: true
'@biomejs/cli-linux-arm64-musl@2.1.4':
'@biomejs/cli-linux-arm64-musl@2.2.0':
optional: true
'@biomejs/cli-linux-arm64@2.1.4':
'@biomejs/cli-linux-arm64@2.2.0':
optional: true
'@biomejs/cli-linux-x64-musl@2.1.4':
'@biomejs/cli-linux-x64-musl@2.2.0':
optional: true
'@biomejs/cli-linux-x64@2.1.4':
'@biomejs/cli-linux-x64@2.2.0':
optional: true
'@biomejs/cli-win32-arm64@2.1.4':
'@biomejs/cli-win32-arm64@2.2.0':
optional: true
'@biomejs/cli-win32-x64@2.1.4':
'@biomejs/cli-win32-x64@2.2.0':
optional: true
'@breezystack/lamejs@1.2.7': {}
@@ -4760,151 +4780,151 @@ snapshots:
'@esbuild/aix-ppc64@0.23.1':
optional: true
'@esbuild/aix-ppc64@0.25.8':
'@esbuild/aix-ppc64@0.25.9':
optional: true
'@esbuild/android-arm64@0.23.1':
optional: true
'@esbuild/android-arm64@0.25.8':
'@esbuild/android-arm64@0.25.9':
optional: true
'@esbuild/android-arm@0.23.1':
optional: true
'@esbuild/android-arm@0.25.8':
'@esbuild/android-arm@0.25.9':
optional: true
'@esbuild/android-x64@0.23.1':
optional: true
'@esbuild/android-x64@0.25.8':
'@esbuild/android-x64@0.25.9':
optional: true
'@esbuild/darwin-arm64@0.23.1':
optional: true
'@esbuild/darwin-arm64@0.25.8':
'@esbuild/darwin-arm64@0.25.9':
optional: true
'@esbuild/darwin-x64@0.23.1':
optional: true
'@esbuild/darwin-x64@0.25.8':
'@esbuild/darwin-x64@0.25.9':
optional: true
'@esbuild/freebsd-arm64@0.23.1':
optional: true
'@esbuild/freebsd-arm64@0.25.8':
'@esbuild/freebsd-arm64@0.25.9':
optional: true
'@esbuild/freebsd-x64@0.23.1':
optional: true
'@esbuild/freebsd-x64@0.25.8':
'@esbuild/freebsd-x64@0.25.9':
optional: true
'@esbuild/linux-arm64@0.23.1':
optional: true
'@esbuild/linux-arm64@0.25.8':
'@esbuild/linux-arm64@0.25.9':
optional: true
'@esbuild/linux-arm@0.23.1':
optional: true
'@esbuild/linux-arm@0.25.8':
'@esbuild/linux-arm@0.25.9':
optional: true
'@esbuild/linux-ia32@0.23.1':
optional: true
'@esbuild/linux-ia32@0.25.8':
'@esbuild/linux-ia32@0.25.9':
optional: true
'@esbuild/linux-loong64@0.23.1':
optional: true
'@esbuild/linux-loong64@0.25.8':
'@esbuild/linux-loong64@0.25.9':
optional: true
'@esbuild/linux-mips64el@0.23.1':
optional: true
'@esbuild/linux-mips64el@0.25.8':
'@esbuild/linux-mips64el@0.25.9':
optional: true
'@esbuild/linux-ppc64@0.23.1':
optional: true
'@esbuild/linux-ppc64@0.25.8':
'@esbuild/linux-ppc64@0.25.9':
optional: true
'@esbuild/linux-riscv64@0.23.1':
optional: true
'@esbuild/linux-riscv64@0.25.8':
'@esbuild/linux-riscv64@0.25.9':
optional: true
'@esbuild/linux-s390x@0.23.1':
optional: true
'@esbuild/linux-s390x@0.25.8':
'@esbuild/linux-s390x@0.25.9':
optional: true
'@esbuild/linux-x64@0.23.1':
optional: true
'@esbuild/linux-x64@0.25.8':
'@esbuild/linux-x64@0.25.9':
optional: true
'@esbuild/netbsd-arm64@0.25.8':
'@esbuild/netbsd-arm64@0.25.9':
optional: true
'@esbuild/netbsd-x64@0.23.1':
optional: true
'@esbuild/netbsd-x64@0.25.8':
'@esbuild/netbsd-x64@0.25.9':
optional: true
'@esbuild/openbsd-arm64@0.23.1':
optional: true
'@esbuild/openbsd-arm64@0.25.8':
'@esbuild/openbsd-arm64@0.25.9':
optional: true
'@esbuild/openbsd-x64@0.23.1':
optional: true
'@esbuild/openbsd-x64@0.25.8':
'@esbuild/openbsd-x64@0.25.9':
optional: true
'@esbuild/openharmony-arm64@0.25.8':
'@esbuild/openharmony-arm64@0.25.9':
optional: true
'@esbuild/sunos-x64@0.23.1':
optional: true
'@esbuild/sunos-x64@0.25.8':
'@esbuild/sunos-x64@0.25.9':
optional: true
'@esbuild/win32-arm64@0.23.1':
optional: true
'@esbuild/win32-arm64@0.25.8':
'@esbuild/win32-arm64@0.25.9':
optional: true
'@esbuild/win32-ia32@0.23.1':
optional: true
'@esbuild/win32-ia32@0.25.8':
'@esbuild/win32-ia32@0.25.9':
optional: true
'@esbuild/win32-x64@0.23.1':
optional: true
'@esbuild/win32-x64@0.25.8':
'@esbuild/win32-x64@0.25.9':
optional: true
'@fastify/busboy@2.1.1': {}
@@ -4917,13 +4937,6 @@ snapshots:
'@iarna/toml@2.2.5': {}
'@iconify/types@2.0.0': {}
'@iconify/vue@5.0.0(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.18(typescript@5.9.2)
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
@@ -5017,12 +5030,24 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.30
optional: true
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
@@ -5036,6 +5061,9 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5':
optional: true
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -5046,6 +5074,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping@0.3.30':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@juggle/resize-observer@3.4.0': {}
'@lokesh.dhakar/quantize@1.4.0': {}
@@ -5421,6 +5455,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.7.0
'@tauri-apps/plugin-global-shortcut@2.3.0':
dependencies:
'@tauri-apps/api': 2.7.0
'@tauri-apps/plugin-http@2.5.1':
dependencies:
'@tauri-apps/api': 2.7.0
@@ -5557,7 +5595,7 @@ snapshots:
dependencies:
'@unocss/core': 66.4.2
'@unocss/vite@66.4.2(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))':
'@unocss/vite@66.4.2(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@unocss/config': 66.4.2
@@ -5568,23 +5606,23 @@ snapshots:
pathe: 2.0.3
tinyglobby: 0.2.14
unplugin-utils: 0.2.5
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
'@vitejs/plugin-vue-jsx@5.0.1(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))':
'@vitejs/plugin-vue-jsx@5.0.1(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0)
'@rolldown/pluginutils': 1.0.0-beta.31
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.28.0)
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vue: 3.5.18(typescript@5.9.2)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.1(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))':
'@vitejs/plugin-vue@6.0.1(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vue: 3.5.18(typescript@5.9.2)
'@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
@@ -5614,13 +5652,13 @@ snapshots:
chai: 5.2.1
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))':
'@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -6507,34 +6545,34 @@ snapshots:
'@esbuild/win32-x64': 0.23.1
optional: true
esbuild@0.25.8:
esbuild@0.25.9:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.8
'@esbuild/android-arm': 0.25.8
'@esbuild/android-arm64': 0.25.8
'@esbuild/android-x64': 0.25.8
'@esbuild/darwin-arm64': 0.25.8
'@esbuild/darwin-x64': 0.25.8
'@esbuild/freebsd-arm64': 0.25.8
'@esbuild/freebsd-x64': 0.25.8
'@esbuild/linux-arm': 0.25.8
'@esbuild/linux-arm64': 0.25.8
'@esbuild/linux-ia32': 0.25.8
'@esbuild/linux-loong64': 0.25.8
'@esbuild/linux-mips64el': 0.25.8
'@esbuild/linux-ppc64': 0.25.8
'@esbuild/linux-riscv64': 0.25.8
'@esbuild/linux-s390x': 0.25.8
'@esbuild/linux-x64': 0.25.8
'@esbuild/netbsd-arm64': 0.25.8
'@esbuild/netbsd-x64': 0.25.8
'@esbuild/openbsd-arm64': 0.25.8
'@esbuild/openbsd-x64': 0.25.8
'@esbuild/openharmony-arm64': 0.25.8
'@esbuild/sunos-x64': 0.25.8
'@esbuild/win32-arm64': 0.25.8
'@esbuild/win32-ia32': 0.25.8
'@esbuild/win32-x64': 0.25.8
'@esbuild/aix-ppc64': 0.25.9
'@esbuild/android-arm': 0.25.9
'@esbuild/android-arm64': 0.25.9
'@esbuild/android-x64': 0.25.9
'@esbuild/darwin-arm64': 0.25.9
'@esbuild/darwin-x64': 0.25.9
'@esbuild/freebsd-arm64': 0.25.9
'@esbuild/freebsd-x64': 0.25.9
'@esbuild/linux-arm': 0.25.9
'@esbuild/linux-arm64': 0.25.9
'@esbuild/linux-ia32': 0.25.9
'@esbuild/linux-loong64': 0.25.9
'@esbuild/linux-mips64el': 0.25.9
'@esbuild/linux-ppc64': 0.25.9
'@esbuild/linux-riscv64': 0.25.9
'@esbuild/linux-s390x': 0.25.9
'@esbuild/linux-x64': 0.25.9
'@esbuild/netbsd-arm64': 0.25.9
'@esbuild/netbsd-x64': 0.25.9
'@esbuild/openbsd-arm64': 0.25.9
'@esbuild/openbsd-x64': 0.25.9
'@esbuild/openharmony-arm64': 0.25.9
'@esbuild/sunos-x64': 0.25.9
'@esbuild/win32-arm64': 0.25.9
'@esbuild/win32-ia32': 0.25.9
'@esbuild/win32-x64': 0.25.9
escalade@3.2.0: {}
@@ -6640,7 +6678,7 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fdir@6.4.6(picomatch@4.0.3):
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -8164,7 +8202,7 @@ snapshots:
acorn: 8.15.0
estree-walker: 3.0.3
magic-string: 0.30.17
unplugin: 2.3.5
unplugin: 2.3.6
optional: true
undici-types@7.8.0: {}
@@ -8206,7 +8244,7 @@ snapshots:
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
unplugin: 2.3.5
unplugin: 2.3.6
unplugin-utils: 0.2.5
optional: true
@@ -8263,6 +8301,14 @@ snapshots:
picomatch: 4.0.2
webpack-virtual-modules: 0.6.2
unplugin@2.3.6:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.15.0
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
optional: true
untyped@2.0.0:
dependencies:
citty: 0.1.6
@@ -8311,7 +8357,7 @@ snapshots:
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -8326,16 +8372,16 @@ snapshots:
- tsx
- yaml
vite-plugin-vue-setup-extend@0.4.0(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)):
vite-plugin-vue-setup-extend@0.4.0(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)):
dependencies:
'@vue/compiler-sfc': 3.5.13
magic-string: 0.25.9
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1):
vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1):
dependencies:
esbuild: 0.25.8
fdir: 6.4.6(picomatch@4.0.3)
esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.46.2
@@ -8353,7 +8399,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
'@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -8371,7 +8417,7 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.1(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite: 7.1.2(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
vite-node: 3.2.4(@types/node@24.0.10)(jiti@2.5.1)(sass@1.90.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)
why-is-node-running: 2.3.0
optionalDependencies:

2
public/icon.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -103,8 +103,8 @@ const compareVersions = (version1, version2) => {
const v2 = version2.replace(/[^0-9.]/g, '').split('.')
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
const num1 = parseInt(v1[i] || '0')
const num2 = parseInt(v2[i] || '0')
const num1 = parseInt(v1[i] || '0', 10)
const num2 = parseInt(v2[i] || '0', 10)
if (num1 > num2) return 1
if (num1 < num2) return -1
}

3
src-tauri/.env Normal file
View File

@@ -0,0 +1,3 @@
RUST_BACKTRACE=1
# APP_ENVIRONMENT=local
APP_ENVIRONMENT=local

View File

@@ -6,5 +6,9 @@
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
<key>NSScreenCaptureUsageDescription</key>
<string>HuLa needs screen recording permission to enable screenshot functionality</string>
<key>NSAccessibilityUsageDescription</key>
<string>HuLa needs accessibility permission to capture screen content</string>
</dict>
</plist>

View File

@@ -1,6 +1,16 @@
{
"identifier": "desktop-capability",
"platforms": ["macOS", "windows", "linux"],
"windows": ["home", "login", "update", "checkupdate"],
"permissions": ["updater:default", "updater:allow-check", "updater:allow-download", "updater:allow-install"]
"windows": ["home", "login", "update", "checkupdate", "capture", "settings", "tray"],
"permissions": [
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -157,6 +157,7 @@ pub fn get_configuration(app_handle: &AppHandle) -> Result<Settings, config::Con
config::ConfigError::Message(format!("Failed to parse APP_ENVIRONMENT: {:?}", e))
})?;
info!("APP_ENVIRONMENT: {}", environment.as_str());
let environment_filename = format!("{}.yaml", environment.as_str());
let is_desktop_dev = cfg!(debug_assertions) && cfg!(desktop);

View File

@@ -183,13 +183,18 @@ pub fn set_height(height: u32, handle: AppHandle) -> Result<(), String> {
///
/// # 参数
/// * `window_label` - 窗口的标签名称
/// * `hide_close_button` - 可选参数是否隐藏关闭按钮默认为false不隐藏
/// * `handle` - Tauri应用句柄
///
/// # 返回
/// * `Result<(), String>` - 成功返回Ok(()), 失败返回错误信息
#[tauri::command]
#[cfg(target_os = "macos")]
pub fn hide_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(), String> {
pub fn hide_title_bar_buttons(
window_label: &str,
hide_close_button: Option<bool>,
handle: AppHandle,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
#[allow(deprecated)]
{
@@ -219,6 +224,11 @@ pub fn hide_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(
hide_button(ns_window, NSWindowButton::NSWindowMiniaturizeButton);
hide_button(ns_window, NSWindowButton::NSWindowZoomButton);
// 根据参数决定是否隐藏关闭按钮
if hide_close_button.unwrap_or(false) {
hide_button(ns_window, NSWindowButton::NSWindowCloseButton);
}
// 设置窗口不可拖动
let _: () = msg_send![ns_window, setMovable: NO];
}
@@ -226,6 +236,54 @@ pub fn hide_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(
Ok(())
}
/// 恢复Mac窗口的标题栏按钮显示
///
/// # 参数
/// * `window_label` - 窗口的标签名称
/// * `handle` - Tauri应用句柄
///
/// # 返回
/// * `Result<(), String>` - 成功返回Ok(()), 失败返回错误信息
#[tauri::command]
#[cfg(target_os = "macos")]
pub fn show_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
#[allow(deprecated)]
{
use cocoa::appkit::NSWindowButton;
use cocoa::base::{NO, YES, id};
use objc::{msg_send, sel, sel_impl};
let webview_window = handle
.get_webview_window(window_label)
.ok_or_else(|| format!("Window '{}' not found", window_label))?;
let ns_window = webview_window
.ns_window()
.map_err(|e| format!("Failed to get NSWindow: {}", e))? as id;
unsafe {
// 显示标题栏按钮的辅助函数
let show_button = |window: id, button_type: NSWindowButton| {
let btn = window.standardWindowButton_(button_type);
if !btn.is_null() {
let _: () = msg_send![btn, setHidden: NO];
}
};
// 显示所有标题栏按钮
show_button(ns_window, NSWindowButton::NSWindowCloseButton);
show_button(ns_window, NSWindowButton::NSWindowMiniaturizeButton);
show_button(ns_window, NSWindowButton::NSWindowZoomButton);
show_button(ns_window, NSWindowButton::NSWindowFullScreenButton);
// 恢复窗口可拖动
let _: () = msg_send![ns_window, setMovable: YES];
}
}
Ok(())
}
#[tauri::command]
pub async fn push_window_payload(
label: String,
@@ -323,3 +381,30 @@ pub fn set_badge_count(count: Option<i64>, handle: AppHandle) -> Result<(), Stri
}
}
}
/// 设置 macOS 窗口级别为屏幕保护程序级别,以覆盖菜单栏
#[tauri::command]
#[cfg(target_os = "macos")]
pub fn set_window_level_above_menubar(window_label: &str, handle: AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
#[allow(deprecated)]
{
use cocoa::base::id;
use objc::{msg_send, sel, sel_impl};
let webview_window = handle
.get_webview_window(window_label)
.ok_or_else(|| format!("Window '{}' not found", window_label))?;
let ns_window = webview_window
.ns_window()
.map_err(|e| format!("Failed to get NSWindow: {}", e))? as id;
unsafe {
// 设置窗口级别为屏幕保护程序级别 (1000),高于菜单栏
let screen_saver_level: i32 = 1000;
let _: () = msg_send![ns_window, setLevel: screen_saver_level];
}
}
Ok(())
}

View File

@@ -3,14 +3,14 @@
mod desktops;
#[cfg(desktop)]
use common::init::CustomInit;
#[cfg(target_os = "macos")]
use common_cmd::hide_title_bar_buttons;
#[cfg(desktop)]
use common_cmd::{
audio, default_window_icon, get_files_meta, get_window_payload, push_window_payload,
screenshot, set_height,
};
#[cfg(target_os = "macos")]
use common_cmd::{hide_title_bar_buttons, set_window_level_above_menubar, show_title_bar_buttons};
#[cfg(target_os = "macos")]
use desktops::app_event;
#[cfg(desktop)]
use desktops::{common_cmd, directory_scanner, init, tray, video_thumbnail::get_video_thumbnail};
@@ -239,20 +239,35 @@ fn setup_logout_listener(app_handle: tauri::AppHandle) {
app_handle.listen("logout", move |_event| {
let app_handle = app_handle_clone.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("[LOGOUT] Starting to close all non-login windows");
tracing::info!("[LOGOUT] Starting to close windows and preserve capture/checkupdate windows");
let all_windows = app_handle.webview_windows();
tracing::info!("[LOGOUT] Found {} windows", all_windows.len());
// 收集需要关闭的窗口
// 收集需要关闭的窗口和需要隐藏的窗口
let mut windows_to_close = Vec::new();
let mut windows_to_hide = Vec::new();
for (label, window) in all_windows {
// 跳过 login 窗口,不关闭它
if label != "login" && label != "tray" {
windows_to_close.push((label, window));
match label.as_str() {
// 这些窗口完全不处理
"login" | "tray" => {},
// 这些窗口只隐藏,不销毁
"capture" | "checkupdate" => {
windows_to_hide.push((label, window));
},
// 其他窗口需要关闭
_ => {
windows_to_close.push((label, window));
}
}
}
// 先隐藏需要保持的窗口
for (label, window) in windows_to_hide {
tracing::info!("[LOGOUT] Hiding window (preserving): {}", label);
let _ = window.hide();
}
// 逐个关闭窗口,添加小延迟以避免并发关闭导致的错误
for (label, window) in windows_to_close {
tracing::info!("[LOGOUT] Closing window: {}", label);
@@ -282,7 +297,7 @@ fn setup_logout_listener(app_handle: tauri::AppHandle) {
}
}
tracing::info!("[LOGOUT] All non-login windows closed successfully");
tracing::info!("[LOGOUT] Logout completed - windows closed and capture/checkupdate windows preserved");
});
});
}
@@ -369,6 +384,10 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
get_video_thumbnail,
#[cfg(target_os = "macos")]
hide_title_bar_buttons,
#[cfg(target_os = "macos")]
show_title_bar_buttons,
#[cfg(target_os = "macos")]
set_window_level_above_menubar,
#[cfg(desktop)]
push_window_payload,
#[cfg(desktop)]

View File

@@ -10,16 +10,9 @@
"devUrl": "http://127.0.0.1:6130"
},
"bundle": {
"resources": [
"tray",
"configuration",
"Info.plist"
],
"resources": ["tray", "configuration", "Info.plist"],
"active": true,
"targets": [
"app",
"dmg"
],
"targets": ["app", "dmg"],
"icon": [
"icons/macos/light/32x32.png",
"icons/macos/light/128x128.png",
@@ -82,9 +75,14 @@
"fullscreen": false,
"transparent": true,
"resizable": false,
"skipTaskbar": false,
"skipTaskbar": true,
"decorations": false,
"visible": false
"visible": false,
"hiddenTitle": true,
"alwaysOnTop": true,
"focus": true,
"titleBarStyle": "Overlay",
"visibleOnAllWorkspaces": true
},
{
"label": "checkupdate",

View File

@@ -96,12 +96,13 @@
{
"label": "capture",
"url": "/capture",
"fullscreen": false,
"fullscreen": true,
"transparent": true,
"resizable": false,
"skipTaskbar": false,
"skipTaskbar": true,
"decorations": false,
"visible": false
"visible": false,
"hiddenTitle": true
},
{
"label": "checkupdate",

View File

@@ -13,6 +13,7 @@ import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { type } from '@tauri-apps/plugin-os'
import { useStorage } from '@vueuse/core'
import { MittEnum, StoresEnum, ThemeEnum } from '@/enums'
import { useGlobalShortcut } from '@/hooks/useGlobalShortcut.ts'
import { useLogin } from '@/hooks/useLogin.ts'
import { useMitt } from '@/hooks/useMitt.ts'
import { useWindow } from '@/hooks/useWindow.ts'
@@ -29,6 +30,8 @@ const { resetLoginState, logout } = useLogin()
const token = useStorage<string | null>('TOKEN', null)
const refreshToken = useStorage<string | null>('REFRESH_TOKEN', null)
const { addListener } = useTauriListener()
// 全局快捷键管理
const { initializeGlobalShortcut, cleanupGlobalShortcut } = useGlobalShortcut()
/** 不需要锁屏的页面 */
const LockExclusion = new Set(['/login', '/tray', '/qrCode', '/about', '/onlineStatus'])
@@ -123,6 +126,11 @@ onMounted(async () => {
}
document.documentElement.dataset.theme = themes.value.content
window.addEventListener('dragstart', preventDrag)
// 只在主窗口中初始化全局快捷键
if (appWindow.label === 'home') {
await initializeGlobalShortcut()
}
/** 开发环境不禁止 */
if (process.env.NODE_ENV !== 'development') {
/** 禁用浏览器默认的快捷键 */
@@ -171,9 +179,14 @@ onMounted(async () => {
)
})
onUnmounted(() => {
onUnmounted(async () => {
window.removeEventListener('contextmenu', (e) => e.preventDefault(), false)
window.removeEventListener('dragstart', preventDrag)
// 只在主窗口中清理全局快捷键
if (appWindow.label === 'home') {
await cleanupGlobalShortcut()
}
})
</script>
<style lang="scss">

File diff suppressed because it is too large Load Diff

View File

@@ -128,9 +128,9 @@ const isAccepted = (targetId: string) => {
const applyList = computed(() => {
return contactStore.requestFriendsList.filter((item) => {
if (props.type === 'friend') {
return item.type === 1
} else {
return item.type === 2
} else {
return item.type === 1
}
})
})
@@ -194,11 +194,16 @@ const loadMoreFriendRequests = async () => {
const handleAgree = async (applyId: string) => {
loadingMap.value[applyId] = true
contactStore.onAcceptFriend(applyId).then(() => {
setTimeout(() => {
loadingMap.value[applyId] = false
}, 600)
})
contactStore
.onHandleInvite({
applyId,
state: 2
})
.then(() => {
setTimeout(() => {
loadingMap.value[applyId] = false
}, 600)
})
}
// 处理好友请求操作(拒绝或忽略)
@@ -206,9 +211,15 @@ const handleFriendAction = async (action: string, applyId: string) => {
loadingMap.value[applyId] = true
try {
if (action === 'reject') {
await contactStore.onRejectFriend(applyId)
await contactStore.onHandleInvite({
applyId,
state: 0
})
} else if (action === 'ignore') {
await contactStore.onIgnoreFriend(applyId)
await contactStore.onHandleInvite({
applyId,
state: 3
})
}
} finally {
setTimeout(() => {

View File

@@ -115,7 +115,8 @@
</template>
<script setup lang="ts">
import { emitTo } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import { join } from '@tauri-apps/api/path'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { open } from '@tauri-apps/plugin-dialog'
@@ -530,8 +531,28 @@ const updateRecentEmojis = (emoji: string) => {
const handleCap = async () => {
const captureWindow = await WebviewWindow.getByLabel('capture')
captureWindow?.show()
await emitTo('capture', 'capture', true)
if (!captureWindow) return
// 设置窗口覆盖整个屏幕(包括菜单栏)
// 使用 JavaScript screen API 获取屏幕尺寸
const screenWidth = window.screen.width * window.devicePixelRatio
const screenHeight = window.screen.height * window.devicePixelRatio
// 设置窗口覆盖整个屏幕(包括菜单栏),不使用负坐标
// 依靠窗口级别设置来确保覆盖菜单栏
await captureWindow.setSize(new LogicalSize(screenWidth, screenHeight))
await captureWindow.setPosition(new LogicalPosition(0, 0))
// 在 macOS 上设置窗口级别以覆盖菜单栏
try {
await invoke('set_window_level_above_menubar', { windowLabel: 'capture' })
} catch (error) {
console.warn('设置窗口级别失败,但继续执行:', error)
}
await captureWindow.show()
await captureWindow.setFocus()
await captureWindow.emit('capture', true)
}
const handleVoiceRecord = () => {

View File

@@ -956,16 +956,20 @@ const startRtcCall = async (callType: CallTypeEnum) => {
}
const createRtcCallWindow = async (isIncoming: boolean, remoteUserId: string, callType: CallTypeEnum) => {
// 获取对方用户ID单聊时使用群聊时可能需要其他逻辑
// 根据是否来电决定窗口尺寸
const windowConfig = isIncoming
? { width: 360, height: 90, minWidth: 360, minHeight: 90 } // 来电通知尺寸
: { width: 500, height: 650, minWidth: 500, minHeight: 650 } // 正常通话尺寸
await createWebviewWindow(
'视频通话', // 窗口标题
'rtcCall', // 窗口标签
500, // 宽度
650, // 高度
windowConfig.width, // 宽度
windowConfig.height, // 高度
undefined, // 不需要关闭其他窗口
false, // 不可调整大小
500, // 最小宽度
650, // 最小高度
windowConfig.minWidth, // 最小宽度
windowConfig.minHeight, // 最小高度
false, // 不透明
false, // 显示窗口
{

View File

@@ -104,8 +104,9 @@
:from-user-uid="item.fromUser.uid"
:is-group="isGroup" />
<!-- 消息为系统消息 -->
<SystemMessage
v-if="item.message.type === MsgEnum.SYSTEM"
v-else-if="item.message.type === MsgEnum.SYSTEM"
:body="item.message.body"
:from-user-uid="item.fromUser.uid" />
@@ -896,7 +897,7 @@ const handleMacSelect = (event: any) => {
}
}
// 处理聊天区域点击事件,用于清除回复样式
// 处理聊天区域点击事件,用于清除回复样式和气泡激活状态
const handleChatAreaClick = (event: any) => {
// 检查点击目标是否为回复相关元素
const isReplyElement =
@@ -904,6 +905,10 @@ const handleChatAreaClick = (event: any) => {
event.target.matches('.active-reply') ||
event.target.closest('.active-reply')
// 检查点击目标是否为气泡相关元素
const isBubbleElement =
event.target.closest('.bubble') || event.target.closest('.bubble-oneself') || event.target.closest('[data-key]')
// 如果点击的不是回复相关元素清除activeReply样式
if (!isReplyElement && activeReply.value) {
nextTick(() => {
@@ -917,6 +922,11 @@ const handleChatAreaClick = (event: any) => {
}
})
}
// 如果点击的不是气泡相关元素清除activeBubble状态
if (!isBubbleElement && activeBubble.value) {
activeBubble.value = ''
}
}
const handleRetry = (item: any) => {

View File

@@ -379,7 +379,7 @@ const handleLoadGroupAnnoun = async (roomId: string) => {
announList.value = [topAnnouncement, ...announList.value.filter((item: any) => !item.top)]
}
}
announNum.value = parseInt(data.total)
announNum.value = parseInt(data.total, 10)
}
// 加载完成后,关闭骨架屏

View File

@@ -152,40 +152,6 @@ watchEffect(() => {
if (alwaysOnTopStatus.value) {
appWindow.setAlwaysOnTop(alwaysOnTopStatus.value as boolean)
}
// 添加关闭事件拦截逻辑 - 只拦截 home 窗口
// if (appWindow.label === 'home' && !unlistenCloseRequested) {
// // 监听原生关闭事件
// appWindow
// .onCloseRequested((event) => {
// // 如果是程序内部触发的关闭操作,不拦截
// if (isProgrammaticClose) {
// return
// }
// // 阻止默认关闭行为
// event.preventDefault()
// if (!tips.value.notTips) {
// tipsRef.show = true
// } else {
// if (tips.value.type === CloseBxEnum.CLOSE) {
// // 用户选择直接退出
// console.log('用户设置为直接退出应用')
// emit(EventEnum.EXIT)
// } else {
// // 用户选择最小化到托盘
// console.log('用户设置为最小化到托盘')
// appWindow.hide()
// }
// }
// })
// .then((unlisten) => {
// console.log('macOS home窗口关闭按钮事件监听器已设置')
// unlistenCloseRequested = unlisten
// })
// .catch((error) => {
// console.error('设置 macOS home窗口关闭按钮监听器失败:', error)
// })
// }
if (escClose.value && type() === 'windows') {
window.addEventListener('keydown', (e) => isEsc(e))
} else {
@@ -249,6 +215,7 @@ const isEsc = (e: KeyboardEvent) => {
// 判断当前是否是最大化
const handleResize = () => {
appWindow.isMaximized().then((res) => {
console.log('ActionBar 检测到窗口最大化状态:', res)
windowMaximized.value = res
})
}
@@ -321,6 +288,11 @@ onUnmounted(() => {
unlistenCloseRequested = null
}
})
// 暴露 windowMaximized 状态
defineExpose({
windowMaximized
})
</script>
<style scoped lang="scss">

View File

@@ -28,8 +28,6 @@ export enum URLEnum {
CHAT = '/im/chat',
/**房间*/
ROOM = '/im/room',
/**oss*/
OSS = '/oss',
/**系统*/
SYSTEM = '/system',
/**验证码*/

View File

@@ -251,6 +251,29 @@ export function useCanvasTool(drawCanvas: any, drawCtx: any, imgCtx: any, screen
}
}
// 重置绘图状态,清除所有绘制历史
const resetState = () => {
drawConfig.value.actions = []
drawConfig.value.undoStack = []
drawConfig.value.isDrawing = false
currentTool.value = ''
console.log('🔄 绘图状态已重置,历史记录已清除')
}
// 停止当前绘图操作
const stopDrawing = () => {
drawConfig.value.isDrawing = false
currentTool.value = ''
closeListen()
console.log('⏹️ 绘图操作已停止')
}
// 清除事件监听
const clearEvents = () => {
closeListen()
console.log('🧹 绘图事件监听已清除')
}
const startListen = () => {
document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('mousemove', handleMouseMove)
@@ -270,6 +293,9 @@ export function useCanvasTool(drawCanvas: any, drawCtx: any, imgCtx: any, screen
drawCircle,
drawArrow,
undo,
redo
redo,
resetState,
stopDrawing,
clearEvents
}
}

View File

@@ -0,0 +1,331 @@
import { invoke } from '@tauri-apps/api/core'
import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import { emitTo, listen } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { register, unregister } from '@tauri-apps/plugin-global-shortcut'
import { useTauriListener } from '@/hooks/useTauriListener'
import { useSettingStore } from '@/stores/setting.ts'
// 快捷键配置接口
type ShortcutConfig = {
/** 配置键名,用于从 store 中读取设置 */
key: keyof NonNullable<ReturnType<typeof useSettingStore>['shortcuts']>
/** 默认快捷键值 */
defaultValue: string
/** 快捷键处理函数 */
handler: () => Promise<void>
/** 监听的更新事件名 */
updateEventName: string
/** 发送注册状态的事件名 */
registrationEventName: string
}
// 全局快捷键状态管理
const globalShortcutStates = new Map<string, string>()
// 防抖状态管理
let togglePanelTimeout: NodeJS.Timeout | null = null
let lastToggleTime = 0
/**
* 全局快捷键管理 Hook
* 负责注册、取消注册和管理全局快捷键
* 使用配置驱动的方式,方便扩展新快捷键
*/
export const useGlobalShortcut = () => {
const settingStore = useSettingStore()
const { addListener } = useTauriListener()
/**
* 确保capture窗口存在
* 如果不存在则创建,如果存在则确保设置了关闭拦截
*/
const ensureCaptureWindow = async () => {
const captureWindow = await WebviewWindow.getByLabel('capture')
if (captureWindow) {
// 设置关闭拦截 - 将关闭转为隐藏
addListener(
captureWindow.onCloseRequested(async (event) => {
event.preventDefault()
await captureWindow.hide()
// 触发重置事件让Screenshot组件重新初始化
await captureWindow.emit('capture-reset', {})
}),
'capture-close-intercept'
)
// 初始状态为隐藏
await captureWindow.hide()
}
return captureWindow
}
/**
* 截图处理函数
*/
const handleScreenshot = async () => {
try {
const homeWindow = await WebviewWindow.getByLabel('home')
if (!homeWindow) return
const captureWindow = await WebviewWindow.getByLabel('capture')
if (!captureWindow) return
// 设置窗口覆盖整个屏幕(包括菜单栏)
const screenWidth = window.screen.width * window.devicePixelRatio
const screenHeight = window.screen.height * window.devicePixelRatio
// 依靠窗口级别设置来确保覆盖菜单栏
await captureWindow.setSize(new LogicalSize(screenWidth, screenHeight))
await captureWindow.setPosition(new LogicalPosition(0, 0))
// 在 macOS 上设置窗口级别以覆盖菜单栏
await invoke('set_window_level_above_menubar', { windowLabel: 'capture' })
await captureWindow.show()
await captureWindow.setFocus()
await captureWindow.emit('capture', true)
console.log('📷 截图窗口已启动')
} catch (error) {
console.error('Failed to open screenshot window:', error)
}
}
/**
* 智能切换主面板显示状态(防抖版本)
* - 如果窗口已显示,则隐藏
* - 如果窗口隐藏或最小化,则显示并聚焦
*/
const handleOpenMainPanel = async () => {
const currentTime = Date.now()
// 防抖如果距离上次操作少于500ms则忽略
if (currentTime - lastToggleTime < 500) {
return
}
// 清除之前的延时操作
if (togglePanelTimeout) {
clearTimeout(togglePanelTimeout)
togglePanelTimeout = null
}
lastToggleTime = currentTime
try {
const homeWindow = await WebviewWindow.getByLabel('home')
if (!homeWindow) {
console.warn('Home window not found')
return
}
// 获取当前窗口状态
const isVisible = await homeWindow.isVisible()
const isMinimized = await homeWindow.isMinimized()
console.log(`🏠 快捷键触发 - 窗口状态: 可见=${isVisible}, 最小化=${isMinimized}`)
if (isVisible && !isMinimized) {
// 窗口当前可见且未最小化,直接隐藏
await homeWindow.hide()
} else {
// 处理最小化状态
if (isMinimized) {
await homeWindow.unminimize()
}
// 显示窗口
await homeWindow.show()
// 延迟设置焦点,确保窗口已完全显示
togglePanelTimeout = setTimeout(async () => {
await homeWindow.setFocus()
}, 50)
}
} catch (error) {
console.error('Failed to toggle main panel:', error)
}
}
// 快捷键配置数组 - 新增快捷键只需在此处添加配置即可
const shortcutConfigs: ShortcutConfig[] = [
{
key: 'screenshot',
defaultValue: 'CmdOrCtrl+Alt+H',
handler: handleScreenshot,
updateEventName: 'shortcut-updated',
registrationEventName: 'shortcut-registration-updated'
},
{
key: 'openMainPanel',
defaultValue: 'CmdOrCtrl+Alt+P',
handler: handleOpenMainPanel,
updateEventName: 'open-main-panel-shortcut-updated',
registrationEventName: 'open-main-panel-shortcut-registration-updated'
}
]
/**
* 通用快捷键注册函数
* @param config 快捷键配置
* @param shortcut 快捷键字符串
*/
const registerShortcut = async (config: ShortcutConfig, shortcut: string): Promise<boolean> => {
try {
const currentShortcut = globalShortcutStates.get(config.key)
// 清理当前快捷键
if (currentShortcut) {
await unregister(currentShortcut)
console.log(`🗑️ 清理快捷键 [${config.key}]: ${currentShortcut}`)
}
// 预防性清理目标快捷键
if (!currentShortcut) {
try {
await unregister(shortcut)
console.log(`🗑️ 预清理快捷键 [${config.key}]: ${shortcut}`)
} catch (_e) {
console.log(` 快捷键 [${config.key}] 未注册: ${shortcut}`)
}
}
// 注册新快捷键
await register(shortcut, config.handler)
globalShortcutStates.set(config.key, shortcut)
console.log(`✅ 快捷键已注册 [${config.key}]: ${shortcut}`)
return true
} catch (error) {
console.error(`❌ 注册快捷键失败 [${config.key}]:`, error)
return false
}
}
/**
* 取消注册快捷键
* @param shortcut 要取消注册的快捷键字符串
*/
const unregisterShortcut = async (shortcut: string) => {
try {
await unregister(shortcut)
console.log(`✅ 成功取消注册快捷键: ${shortcut}`)
} catch (error) {
console.error(`❌ 取消注册快捷键失败: ${shortcut}`, error)
}
}
/**
* 强制清理快捷键残留
*/
const forceCleanupShortcuts = async (shortcuts: string[]) => {
for (const shortcut of shortcuts) {
try {
await unregister(shortcut)
} catch (_e) {
console.log(`🧹 强制清理 ${shortcut} (可能未注册)`)
}
}
}
/**
* 通用快捷键更新处理函数
* @param config 快捷键配置
* @param newShortcut 新快捷键
*/
const handleShortcutUpdate = async (config: ShortcutConfig, newShortcut: string) => {
const oldShortcut = globalShortcutStates.get(config.key)
// 强制清理旧快捷键
const shortcutsToClean = [oldShortcut, newShortcut].filter(Boolean) as string[]
await forceCleanupShortcuts(shortcutsToClean)
// 清除状态,准备重新注册
globalShortcutStates.delete(config.key)
// 尝试注册新快捷键
console.log(`🔧 [Home] 开始注册新快捷键 [${config.key}]: ${newShortcut}`)
const success = await registerShortcut(config, newShortcut)
// 如果注册失败且有旧快捷键,尝试回滚
if (!success && oldShortcut) {
globalShortcutStates.delete(config.key)
const rollbackSuccess = await registerShortcut(config, oldShortcut)
console.log(`🔄 [Home] 快捷键回滚结果 [${config.key}]: ${rollbackSuccess ? '成功' : '失败'}`)
}
// 通知设置页面注册状态更新
await emitTo('settings', config.registrationEventName, {
shortcut: newShortcut,
registered: success
})
console.log(`📡 [Home] 已通知 settings 窗口快捷键状态更新 [${config.key}]: ${success ? '已注册' : '未注册'}`)
}
/**
* 初始化全局快捷键
* 根据配置自动注册所有快捷键并监听更新事件
*/
const initializeGlobalShortcut = async () => {
// 确保capture窗口存在
await ensureCaptureWindow()
// 批量注册所有配置的快捷键
for (const config of shortcutConfigs) {
const savedShortcut = settingStore.shortcuts?.[config.key] || config.defaultValue
await registerShortcut(config, savedShortcut)
// 监听每个快捷键的更新事件
addListener(
listen(config.updateEventName, (event) => {
const newShortcut = (event.payload as any)?.shortcut
if (newShortcut) {
console.log(`📡 [Home] 收到快捷键更新事件 [${config.key}]: ${newShortcut}`)
handleShortcutUpdate(config, newShortcut)
} else {
console.warn(`📡 [Home] 收到无效的快捷键更新事件 [${config.key}]:`, event.payload)
}
}),
config.updateEventName
)
}
}
/**
* 清理全局快捷键
* 取消注册所有快捷键并清理状态
*/
const cleanupGlobalShortcut = async () => {
// 清理防抖定时器
if (togglePanelTimeout) {
clearTimeout(togglePanelTimeout)
togglePanelTimeout = null
}
// 取消注册所有已注册的快捷键
for (const shortcut of globalShortcutStates.values()) {
await unregisterShortcut(shortcut)
}
// 清理状态
globalShortcutStates.clear()
}
return {
// 处理函数
handleScreenshot,
handleOpenMainPanel,
// 核心功能
initializeGlobalShortcut,
cleanupGlobalShortcut,
ensureCaptureWindow,
// 工具函数(主要用于测试和调试)
registerShortcut: (config: ShortcutConfig, shortcut: string) => registerShortcut(config, shortcut),
unregisterShortcut,
// 配置信息(用于外部访问)
shortcutConfigs
}
}

View File

@@ -1,4 +1,5 @@
import { emit } from '@tauri-apps/api/event'
import { info } from '@tauri-apps/plugin-log'
import { type } from '@tauri-apps/plugin-os'
import { EventEnum, RoomTypeEnum, TauriCommand } from '@/enums'
import { useWindow } from '@/hooks/useWindow.ts'
@@ -37,9 +38,12 @@ export const useLogin = () => {
* 登出账号
*/
const logout = async () => {
info('登出账号')
const { createWebviewWindow } = useWindow()
isTrayMenuShow.value = false
try {
// ws 退出连接
await invokeSilently('ws_disconnect')
await invokeSilently(TauriCommand.UPDATE_USER_LAST_OPT_TIME)
// 创建登录窗口
await createWebviewWindow('登录', 'login', 320, 448, undefined, false, 320, 448)

View File

@@ -13,6 +13,7 @@ export const useTauriListener = () => {
const listeners: Promise<UnlistenFn>[] = []
const instance = getCurrentInstance()
const windowLabel = WebviewWindow.getCurrent().label
let isComponentMounted = true
/**
* 添加事件监听器
@@ -59,6 +60,9 @@ export const useTauriListener = () => {
* 清理当前组件的监听器
*/
const cleanup = async () => {
// 标记组件为未挂载状态
isComponentMounted = false
// 只有当存在监听器时才打印日志和执行清理
if (listeners.length > 0) {
const componentName = instance?.type?.name || instance?.type?.__name || '未知组件'
@@ -143,7 +147,12 @@ export const useTauriListener = () => {
// 只在组件实例存在时才注册 onUnmounted 钩子
if (instance) {
onUnmounted(cleanup)
onUnmounted(() => {
// 检查组件是否仍然挂载,避免重复执行清理
if (isComponentMounted) {
cleanup()
}
})
}
return {

View File

@@ -583,8 +583,6 @@ export const useWebRtc = (roomId: string, remoteUserId: string, callType: CallTy
info('收到 offer')
connectionStatus.value = RTCCallStatus.CALLING
await nextTick()
// 开启铃声
startBell()
await getDevices()
const hasLocalStream = await getLocalStream(video ? CallTypeEnum.VIDEO : CallTypeEnum.AUDIO)

View File

@@ -24,8 +24,7 @@
:current-label="appWindow.label" />
<!-- 顶部搜索栏 -->
<header
class="mt-30px w-full h-40px flex flex-col items-center border-b-(1px solid [--right-chat-footer-line-color])">
<header class="mt-30px pb-10px flex-1 flex-col-x-center border-b-(1px solid [--right-chat-footer-line-color])">
<div class="flex-center gap-5px w-full pr-16px pl-16px box-border">
<n-input
id="search"

View File

@@ -15,13 +15,7 @@
<!-- 聊天界面背景图标 -->
<div v-else class="flex-center size-full select-none">
<img
v-if="imgTheme === ThemeEnum.DARK && themes.versatile === 'default' && !isDetails"
class="w-110px h-100px"
src="@/assets/img/hula_bg_d.svg"
alt="" />
<img v-else-if="imgTheme === ThemeEnum.DARK" class="w-110px h-100px" src="@/assets/img/hula-bg-h.png" alt="" />
<img v-else class="svg-icon w-110px h-100px" src="@/assets/img/hula_bg_l.png" alt="" />
<img class="w-150px h-140px" src="/logoD.png" alt="" />
</div>
</div>
</main>

View File

@@ -11,10 +11,10 @@ export class IosAdapter implements MobileClientInterface {
// 手动获取其安全区域值
const insets: SafeArea = {
top: parseInt(rootStyle.getPropertyValue('--safe-area-inset-top') || '0'),
bottom: parseInt(rootStyle.getPropertyValue('--safe-area-inset-bottom') || '0'),
left: parseInt(rootStyle.getPropertyValue('--safe-area-inset-left') || '0'),
right: parseInt(rootStyle.getPropertyValue('--safe-area-inset-right') || '0')
top: parseInt(rootStyle.getPropertyValue('--safe-area-inset-top') || '0', 10),
bottom: parseInt(rootStyle.getPropertyValue('--safe-area-inset-bottom') || '0', 10),
left: parseInt(rootStyle.getPropertyValue('--safe-area-inset-left') || '0', 10),
right: parseInt(rootStyle.getPropertyValue('--safe-area-inset-right') || '0', 10)
}
return insets

View File

@@ -267,6 +267,11 @@ const routes: Array<RouteRecordRaw> = [
path: '/manageStore',
name: 'manageStore',
component: () => import('@/views/moreWindow/settings/ManageStore.vue')
},
{
path: '/shortcut',
name: 'shortcut',
component: () => import('@/views/moreWindow/settings/Shortcut.vue')
}
]
},

View File

@@ -81,12 +81,8 @@ export default {
requestFriendList: (params?: any) => GET<ListResponse<RequestFriendItem>>(urls.requestFriendList, params),
/** 发送添加好友请求 */
sendAddFriendRequest: (params: { targetUid: string; msg: string }) => POST(urls.sendAddFriendRequest, params),
/** 同意好友申请 */
applyFriendRequest: (params: { applyId: string }) => PUT(urls.sendAddFriendRequest, params),
/** 忽略好友申请 */
ignoreFriendRequest: (params: { applyId: string }) => PUT(urls.ignoreFriendRequest, params),
/** 拒绝好友申请 */
rejectFriendRequest: (params: { applyId: string }) => PUT(urls.rejectFriendRequest, params),
/** 同意邀请进群或好友申请1 同意 2 拒绝 */
handleInviteApi: (body: { applyId: string; state: number }) => POST(urls.handleInvite, body),
/** 删除好友 */
deleteFriend: (params: { targetUid: string }) => DELETE(urls.deleteFriend, params),
/** 好友申请未读数 */

View File

@@ -55,7 +55,7 @@ const signUtils = {
*/
getTencentSign(text: string, secretId: string, secretKey: string, timestamp: string): string {
// 根据时间戳生成UTC日期字符串
const date = new Date(parseInt(timestamp) * 1000)
const date = new Date(parseInt(timestamp, 10) * 1000)
const dateStr = date.toISOString().split('T')[0]
const service = 'tmt' // 服务名称

View File

@@ -44,11 +44,8 @@ export default {
getContactList: `${prefix + URLEnum.USER}/friend/page`, // 联系人列表
requestFriendList: `${prefix + URLEnum.ROOM}/apply/page`, // 好友申请、群聊邀请列表
newFriendCount: `${prefix + URLEnum.ROOM}/apply/unread`, // 申请未读数
acceptInvite: `${prefix + URLEnum.ROOM}/apply/accept`, // 审批别人邀请的进群
handleInvite: `${prefix + URLEnum.ROOM}/apply/handler/apply`, // 审批别人邀请的进群
sendAddFriendRequest: `${prefix + URLEnum.ROOM}/apply/apply`, // 申请好友\同意申请
rejectFriendRequest: `${prefix + URLEnum.ROOM}/apply/reject`, // 拒绝好友申请
ignoreFriendRequest: `${prefix + URLEnum.ROOM}/apply/ignore`, // 忽略好友申请
deleteFriendRequest: `${prefix + URLEnum.ROOM}/apply/delete`, // 删除好友、群聊申请
deleteFriend: `${prefix + URLEnum.USER}/friend`, // 删除好友
modifyFriendRemark: `${prefix + URLEnum.USER}/friend/updateRemark`, // 修改好友备注
@@ -76,7 +73,7 @@ export default {
updateMyRoomInfo: `${prefix + URLEnum.ROOM}/updateMyRoomInfo`, // 修改“我”的群聊名称
searchGroup: `${prefix + URLEnum.ROOM}/search`, // 搜索群聊
applyGroup: `${prefix + URLEnum.ROOM}/apply/group`, // 申请加群
applyHandle: `${prefix + URLEnum.ROOM}/apply/handle`, // 处理加群申请 [仅仅管理员、群主可调用]
applyHandle: `${prefix + URLEnum.ROOM}/apply/adminHandleApply`, // 处理加群申请 [仅仅管理员、群主可调用]
applyGroupList: `${prefix + URLEnum.ROOM}/apply/group/list`, // 申请加群列表 [仅仅管理员、群主可见]
getAnnouncementList: `${prefix + URLEnum.ROOM}/announcement/list`, // 获取群公告
pushAnnouncement: `${prefix + URLEnum.ROOM}/announcement/push`, // 发布群公告
@@ -88,9 +85,9 @@ export default {
sendCaptcha: `${prefix + URLEnum.TOKEN}/anyTenant/sendEmailCode`, // 发送验证码到用户邮箱
// -------------- 系统相关 ---------------
fileUpload: `${prefix + URLEnum.SYSTEM + URLEnum.OSS}/upload/url`, // 文件上传
fileUpload: `${prefix + URLEnum.SYSTEM}/upload/url`, // 文件上传
initConfig: `${prefix + URLEnum.SYSTEM}/anyTenant/config/init`, // 获取配置文件
getQiniuToken: `${prefix + URLEnum.TOKEN}/anyTenant/ossToken`, // 获取七牛云上传token
getQiniuToken: `${prefix + URLEnum.SYSTEM}/anyTenant/ossToken`, // 获取七牛云上传token
// -------------- token相关 ---------------
register: `${prefix + URLEnum.TOKEN}/anyTenant/registerByEmail`, // 注册

View File

@@ -21,6 +21,7 @@ let webSocketService: any
if (USE_RUST_WEBSOCKET) {
// 使用 Rust WebSocket 实现
// TODO: 这里会初始化多次,会根据窗口来初始化,需要实现单例模式
info('🦀 使用 Rust WebSocket 实现')
webSocketService = import('./webSocketRust').then((module) => module.default)
} else {

View File

@@ -41,13 +41,69 @@ export interface WebSocketEvent {
* Rust WebSocket 客户端封装
* 提供与原始 WebSocket Worker 兼容的接口
*/
/**
* 监听器管理器,类似 AbortController
*/
class ListenerController {
private listeners: Set<UnlistenFn> = new Set()
private isAborted = false
add(unlisten: UnlistenFn): void {
if (this.isAborted) {
// 如果已经中止,立即清理新添加的监听器
unlisten()
return
}
this.listeners.add(unlisten)
}
async abort(): Promise<void> {
if (this.isAborted) return
this.isAborted = true
const cleanupPromises: Promise<void>[] = []
// 并行执行所有清理操作
for (const unlisten of this.listeners) {
cleanupPromises.push(
Promise.resolve()
.then(() => unlisten())
.catch((err) => {
error(`[ListenerController] 清理监听器失败: ${err}`)
})
)
}
// 等待所有清理完成(设置超时防止阻塞)
try {
await Promise.race([
Promise.all(cleanupPromises),
new Promise((_, reject) => setTimeout(() => reject(new Error('清理超时')), 5000))
])
} catch (err) {
warn(`[ListenerController] 部分监听器清理可能未完成: ${err}`)
}
this.listeners.clear()
info(`[ListenerController] 已清理所有监听器`)
}
get size(): number {
return this.listeners.size
}
get aborted(): boolean {
return this.isAborted
}
}
class RustWebSocketClient {
private eventListener: UnlistenFn | null = null
private listenerController: ListenerController = new ListenerController()
private isInitialized = false
constructor() {
info('[RustWS] Rust WebSocket 客户端初始化')
this.setupEventListener()
}
/**
@@ -179,11 +235,20 @@ class RustWebSocketClient {
*/
private async setupEventListener(): Promise<void> {
try {
info(`[RustWS] 开始设置事件监听器,当前业务监听器数量: ${this.listenerController.size}`)
// 清理旧的监听器
if (this.eventListener) {
this.eventListener()
info('[RustWS] 已清理主事件监听器')
}
// 高效清理所有业务监听器(并行 + 超时)
const oldListenerCount = this.listenerController.size
await this.listenerController.abort()
this.listenerController = new ListenerController()
info(`[RustWS] 已高效清理 ${oldListenerCount} 个业务监听器`)
// 监听 WebSocket 事件
this.eventListener = await listen<WebSocketEvent>('websocket-event', (event) => {
this.handleWebSocketEvent(event.payload)
@@ -192,7 +257,7 @@ class RustWebSocketClient {
// 设置业务消息监听器
await this.setupBusinessMessageListeners()
info('[RustWS] 事件监听器设置完成')
info(`[RustWS] 事件监听器设置完成,新的业务监听器数量: ${this.listenerController.size}`)
} catch (err) {
error(`[RustWS] 设置事件监听器失败: ${err}`)
}
@@ -267,168 +332,231 @@ class RustWebSocketClient {
*/
private async setupBusinessMessageListeners(): Promise<void> {
// 连接状态相关事件
await listen('ws-connection-lost', (event: any) => {
warn(`[RustWS] 收到连接丢失事件: ${JSON.stringify(event.payload)}`)
this.handleConnectionLost(event.payload)
})
this.listenerController.add(
await listen('ws-connection-lost', (event: any) => {
warn(`[RustWS] 收到连接丢失事件: ${JSON.stringify(event.payload)}`)
this.handleConnectionLost(event.payload)
})
)
// 登录相关事件
await listen('ws-login-qr-code', (event: any) => {
info('获取二维码')
useMitt.emit(WsResponseMessageType.LOGIN_QR_CODE, event.payload)
})
this.listenerController.add(
await listen('ws-login-qr-code', (event: any) => {
info('获取二维码')
useMitt.emit(WsResponseMessageType.LOGIN_QR_CODE, event.payload)
})
)
await listen('ws-waiting-authorize', () => {
info('等待授权')
useMitt.emit(WsResponseMessageType.WAITING_AUTHORIZE)
})
this.listenerController.add(
await listen('ws-waiting-authorize', () => {
info('等待授权')
useMitt.emit(WsResponseMessageType.WAITING_AUTHORIZE)
})
)
await listen('ws-login-success', (event: any) => {
info('登录成功')
useMitt.emit(WsResponseMessageType.LOGIN_SUCCESS, event.payload)
})
this.listenerController.add(
await listen('ws-login-success', (event: any) => {
info('登录成功')
useMitt.emit(WsResponseMessageType.LOGIN_SUCCESS, event.payload)
})
)
// 消息相关事件
await listen('ws-receive-message', (event: any) => {
info(`[ws]收到消息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.RECEIVE_MESSAGE, event.payload)
})
const listenerIndex = this.listenerController.size
this.listenerController.add(
await listen('ws-receive-message', (event: any) => {
info(`[ws]收到消息[监听器${listenerIndex}]: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.RECEIVE_MESSAGE, event.payload)
})
)
// 消息相关事件
await listen('ws-join-group', async (event: any) => {
info(`[ws]收到消息: ${JSON.stringify(event.payload)}`)
// 更新群聊列表
await contactStore.getGroupChatList()
})
// 群组相关事件
this.listenerController.add(
await listen('ws-join-group', async (event: any) => {
info(`[ws]加入群组: ${JSON.stringify(event.payload)}`)
// 更新群聊列表
await contactStore.getGroupChatList()
})
)
await listen('ws-msg-recall', (event: any) => {
info('撤回')
useMitt.emit(WsResponseMessageType.MSG_RECALL, event.payload)
})
this.listenerController.add(
await listen('ws-msg-recall', (event: any) => {
info('撤回')
useMitt.emit(WsResponseMessageType.MSG_RECALL, event.payload)
})
)
await listen('ws-msg-mark-item', (event: any) => {
info(`消息标记: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.MSG_MARK_ITEM, event.payload)
})
this.listenerController.add(
await listen('ws-msg-mark-item', (event: any) => {
info(`消息标记: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.MSG_MARK_ITEM, event.payload)
})
)
// 用户状态相关事件
await listen('ws-online', (event: any) => {
info(`上线: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ONLINE, event.payload)
})
this.listenerController.add(
await listen('ws-online', (event: any) => {
info(`上线: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ONLINE, event.payload)
})
)
await listen('ws-offline', () => {
info('下线')
useMitt.emit(WsResponseMessageType.OFFLINE)
})
this.listenerController.add(
await listen('ws-offline', () => {
info('下线')
useMitt.emit(WsResponseMessageType.OFFLINE)
})
)
await listen('ws-user-state-change', (event: any) => {
info(`用户状态改变: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.USER_STATE_CHANGE, event.payload)
})
this.listenerController.add(
await listen('ws-user-state-change', (event: any) => {
info(`用户状态改变: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.USER_STATE_CHANGE, event.payload)
})
)
// 好友相关事件
await listen('ws-request-new-friend', (event: any) => {
info('好友申请')
useMitt.emit(WsResponseMessageType.REQUEST_NEW_FRIEND, event.payload)
})
this.listenerController.add(
await listen('ws-request-new-friend', (event: any) => {
info('好友申请')
useMitt.emit(WsResponseMessageType.REQUEST_NEW_FRIEND, event.payload)
})
)
await listen('ws-request-approval-friend', (event: any) => {
info(`同意好友申请: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.REQUEST_APPROVAL_FRIEND, event.payload)
})
this.listenerController.add(
await listen('ws-request-approval-friend', (event: any) => {
info(`同意好友申请: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.REQUEST_APPROVAL_FRIEND, event.payload)
})
)
await listen('ws-new-friend-session', (event: any) => {
info('成员变动')
useMitt.emit(WsResponseMessageType.NEW_FRIEND_SESSION, event.payload)
})
this.listenerController.add(
await listen('ws-new-friend-session', (event: any) => {
info('成员变动')
useMitt.emit(WsResponseMessageType.NEW_FRIEND_SESSION, event.payload)
})
)
// 房间/群聊相关事件
await listen('ws-room-info-change', (event: any) => {
info(`群主修改群聊信息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_INFO_CHANGE, event.payload)
})
this.listenerController.add(
await listen('ws-room-info-change', (event: any) => {
info(`群主修改群聊信息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_INFO_CHANGE, event.payload)
})
)
await listen('ws-my-room-info-change', (event: any) => {
info(`自己修改我在群里的信息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.MY_ROOM_INFO_CHANGE, event.payload)
})
this.listenerController.add(
await listen('ws-my-room-info-change', (event: any) => {
info(`自己修改我在群里的信息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.MY_ROOM_INFO_CHANGE, event.payload)
})
)
await listen('ws-room-group-notice-msg', (event: any) => {
info(`发布群公告: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_GROUP_NOTICE_MSG, event.payload)
})
this.listenerController.add(
await listen('ws-room-group-notice-msg', (event: any) => {
info(`发布群公告: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_GROUP_NOTICE_MSG, event.payload)
})
)
await listen('ws-room-edit-group-notice-msg', (event: any) => {
info(`编辑群公告: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_EDIT_GROUP_NOTICE_MSG, event.payload)
})
this.listenerController.add(
await listen('ws-room-edit-group-notice-msg', (event: any) => {
info(`编辑群公告: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_EDIT_GROUP_NOTICE_MSG, event.payload)
})
)
await listen('ws-room-dissolution', (event: any) => {
info(`群解散: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_DISSOLUTION, event.payload)
})
this.listenerController.add(
await listen('ws-room-dissolution', (event: any) => {
info(`群解散: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.ROOM_DISSOLUTION, event.payload)
})
)
// 视频通话相关事件
await listen('ws-video-call-request', (event: any) => {
info(`收到通话请求: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.VideoCallRequest, event.payload)
})
this.listenerController.add(
await listen('ws-video-call-request', (event: any) => {
info(`收到通话请求: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.VideoCallRequest, event.payload)
})
)
await listen('ws-call-accepted', (event: any) => {
info(`通话被接受: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CallAccepted, event.payload)
})
this.listenerController.add(
await listen('ws-call-accepted', (event: any) => {
info(`通话被接受: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CallAccepted, event.payload)
})
)
await listen('ws-call-rejected', (event: any) => {
info(`通话被拒绝: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CallRejected, event.payload)
})
this.listenerController.add(
await listen('ws-call-rejected', (event: any) => {
info(`通话被拒绝: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CallRejected, event.payload)
})
)
await listen('ws-room-closed', (event: any) => {
info(`房间已关闭: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.RoomClosed, event.payload)
})
this.listenerController.add(
await listen('ws-room-closed', (event: any) => {
info(`房间已关闭: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.RoomClosed, event.payload)
})
)
await listen('ws-webrtc-signal', (event: any) => {
info(`收到信令消息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.WEBRTC_SIGNAL, event.payload)
})
this.listenerController.add(
await listen('ws-webrtc-signal', (event: any) => {
info(`收到信令消息: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.WEBRTC_SIGNAL, event.payload)
})
)
await listen('ws-join-video', (event: any) => {
info(`用户加入房间: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.JoinVideo, event.payload)
})
this.listenerController.add(
await listen('ws-join-video', (event: any) => {
info(`用户加入房间: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.JoinVideo, event.payload)
})
)
await listen('ws-leave-video', (event: any) => {
info(`用户离开房间: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.LeaveVideo, event.payload)
})
this.listenerController.add(
await listen('ws-leave-video', (event: any) => {
info(`用户离开房间: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.LeaveVideo, event.payload)
})
)
await listen('ws-dropped', (event: any) => {
useMitt.emit(WsResponseMessageType.DROPPED, event.payload)
})
this.listenerController.add(
await listen('ws-dropped', (event: any) => {
useMitt.emit(WsResponseMessageType.DROPPED, event.payload)
})
)
await listen('ws-cancel', (event: any) => {
info(`已取消通话: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CANCEL, event.payload)
})
this.listenerController.add(
await listen('ws-cancel', (event: any) => {
info(`已取消通话: ${JSON.stringify(event.payload)}`)
useMitt.emit(WsResponseMessageType.CANCEL, event.payload)
})
)
// 系统相关事件
await listen('ws-token-expired', (event: any) => {
info('账号在其他设备登录')
useMitt.emit(WsResponseMessageType.TOKEN_EXPIRED, event.payload)
})
this.listenerController.add(
await listen('ws-token-expired', (event: any) => {
info('账号在其他设备登录')
useMitt.emit(WsResponseMessageType.TOKEN_EXPIRED, event.payload)
})
)
await listen('ws-invalid-user', (event: any) => {
info('无效用户')
useMitt.emit(WsResponseMessageType.INVALID_USER, event.payload)
})
this.listenerController.add(
await listen('ws-invalid-user', (event: any) => {
info('无效用户')
useMitt.emit(WsResponseMessageType.INVALID_USER, event.payload)
})
)
// 未知消息类型
await listen('ws-unknown-message', (event: any) => {
info(`接收到未处理类型的消息: ${JSON.stringify(event.payload)}`)
})
this.listenerController.add(
await listen('ws-unknown-message', (event: any) => {
info(`接收到未处理类型的消息: ${JSON.stringify(event.payload)}`)
})
)
}
/**
@@ -503,6 +631,12 @@ class RustWebSocketClient {
info(`[RustWS] 当前窗口 [${windowLabel}] 需要 WebSocket 连接`)
// 首次初始化时设置事件监听器
if (this.listenerController.size === 0) {
info('[RustWS] 首次初始化,设置事件监听器')
await this.setupEventListener()
}
// 检查当前连接状态
const isConnected = await this.isConnected()
@@ -515,6 +649,10 @@ class RustWebSocketClient {
} catch (err) {
warn(`[RustWS] 检查连接状态失败,尝试智能重连: ${err}`)
try {
// 确保事件监听器已设置
if (this.listenerController.size === 0) {
await this.setupEventListener()
}
await this.initConnect()
} catch (reconnectError) {
error(`[RustWS] 智能重连失败: ${reconnectError}`)
@@ -551,49 +689,65 @@ class RustWebSocketClient {
/**
* 清理资源
*/
destroy(): void {
async destroy(): Promise<void> {
if (this.eventListener) {
this.eventListener()
this.eventListener = null
}
// 高效清理所有业务监听器(并行 + 超时)
await this.listenerController.abort()
if (this.isInitialized) {
this.disconnect()
await this.disconnect()
}
info('[RustWS] WebSocket 客户端已销毁')
}
}
info('创建RustWebSocketClient')
// 创建全局实例
const rustWebSocketClient = new RustWebSocketClient()
// 防止重复初始化
let isModuleInitialized = false
// 延迟执行智能初始化,确保页面加载完成
setTimeout(() => {
rustWebSocketClient.smartInitConnect()
}, 100)
if (!isModuleInitialized) {
isModuleInitialized = true
setTimeout(() => {
info('[RustWS] 模块级别智能初始化开始')
rustWebSocketClient.smartInitConnect()
}, 100)
}
// 使用 Tauri 原生事件监听窗口焦点变化(跨平台兼容)
;(async () => {
try {
const currentWindow = getCurrentWebviewWindow()
// 防止重复设置窗口焦点监听器
let isWindowListenerInitialized = false
// 监听窗口获得焦点事件
await currentWindow.listen('tauri://focus', () => {
info('[RustWS] 窗口获得焦点,设置应用状态为前台')
rustWebSocketClient.setAppBackgroundState(false)
})
if (!isWindowListenerInitialized) {
isWindowListenerInitialized = true
;(async () => {
try {
const currentWindow = getCurrentWebviewWindow()
// 监听窗口失去焦点事件
await currentWindow.listen('tauri://blur', () => {
info('[RustWS] 窗口失去焦点,设置应用状态为台')
rustWebSocketClient.setAppBackgroundState(true)
})
// 监听窗口获得焦点事件
await currentWindow.listen('tauri://focus', () => {
info('[RustWS] 窗口获得焦点,设置应用状态为台')
rustWebSocketClient.setAppBackgroundState(false)
})
info('[RustWS] 窗口焦点事件监听器已设置')
} catch (err) {
error(`[RustWS] 设置窗口焦点监听器失败: ${err}`)
}
})()
// 监听窗口失去焦点事件
await currentWindow.listen('tauri://blur', () => {
info('[RustWS] 窗口失去焦点,设置应用状态为后台')
rustWebSocketClient.setAppBackgroundState(true)
})
info('[RustWS] 窗口焦点事件监听器已设置')
} catch (err) {
error(`[RustWS] 设置窗口焦点监听器失败: ${err}`)
}
})()
}
export default rustWebSocketClient

View File

@@ -333,7 +333,8 @@ export const useChatStore = defineStore(
// 用会话列表第一个去请求消息列表
await getMsgList()
// 请求第一个群成员列表
currentRoomType.value === RoomTypeEnum.GROUP && (await groupStore.getGroupUserList(data[0].roomId))
currentRoomType.value === RoomTypeEnum.GROUP &&
(await groupStore.getGroupUserList(globalStore.currentSession.roomId))
// 初始化所有用户基本信息
userStore.isSign && (await cachedStore.initAllUserBaseInfo())
// 联系人列表
@@ -491,7 +492,7 @@ export const useChatStore = defineStore(
const getMsgIndex = (msgId: string) => {
if (!msgId) return -1
const keys = currentMessageMap.value ? Array.from(currentMessageMap.value.keys()) : []
return keys.findIndex((key) => key === msgId)
return keys.indexOf(msgId)
}
// 更新所有标记类型的数量

View File

@@ -135,27 +135,20 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
}
}
// 初始化时默认执行一次加载
// getContactList()
// getRequestFriendsList()
/**
* 接受好友请求
* @param applyId 好友申请ID
* 处理流程:
* 1. 调用接口同意好友申请
* 2. 刷新好友申请列表
* 3. 刷新好友列表
* 4. 更新当前选中联系人的状态
* 5. 更新未读数
* 处理好友/群申请
* @param apply 好友申请信息
* @param state 处理状态 0拒绝 2同意 3忽略
*/
const onAcceptFriend = async (applyId: string) => {
const onHandleInvite = async (apply: { applyId: string; state: number }) => {
// 同意好友申请
apis.applyFriendRequest({ applyId }).then(async () => {
apis.handleInviteApi(apply).then(async () => {
// 刷新好友申请列表
await getRequestFriendsList(true)
// 刷新好友列表
await getContactList(true)
// 获取最新的未读数
await getNewFriendCount()
// 更新当前选中联系人的状态
if (globalStore.currentSelectedContact) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -167,42 +160,6 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
})
}
/**
* 拒绝好友请求
* @param applyId 好友申请ID
* 处理流程:
* 1. 调用接口拒绝好友申请
* 2. 刷新好友申请列表
* 3. 更新未读数
*/
const onRejectFriend = async (applyId: string) => {
// 拒绝好友申请
apis.rejectFriendRequest({ applyId }).then(async () => {
// 刷新好友申请列表
await getRequestFriendsList(true)
// 获取最新的未读数
await getNewFriendCount()
})
}
/**
* 忽略好友请求
* @param applyId 好友申请ID
* 处理流程:
* 1. 调用接口忽略好友申请
* 2. 刷新好友申请列表
* 3. 更新未读数
*/
const onIgnoreFriend = async (applyId: string) => {
// 忽略好友申请
apis.ignoreFriendRequest({ applyId }).then(async () => {
// 刷新好友申请列表
await getRequestFriendsList(true)
// 获取最新的未读数
await getNewFriendCount()
})
}
/**
* 删除好友
* @param uid 要删除的好友用户ID
@@ -230,9 +187,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
requestFriendsList,
contactsOptions,
requestFriendsOptions,
onAcceptFriend,
onRejectFriend,
onIgnoreFriend,
onDeleteContact
onDeleteContact,
onHandleInvite
}
})

View File

@@ -32,6 +32,10 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
isDouble: true,
translate: 'tencent'
},
shortcuts: {
screenshot: 'CmdOrCtrl+Alt+H',
openMainPanel: 'CmdOrCtrl+Alt+P'
},
page: {
shadow: true,
fonts: 'PingFang',
@@ -69,6 +73,27 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
/** 设置菜单显示模式 */
setShowMode(showMode: ShowModeEnum): void {
this.showMode = showMode
},
/** 设置截图快捷键 */
setScreenshotShortcut(shortcut: string) {
if (!this.shortcuts) {
this.shortcuts = { screenshot: 'CmdOrCtrl+Alt+H', openMainPanel: 'CmdOrCtrl+Alt+P' }
}
this.shortcuts.screenshot = shortcut
},
/** 设置打开主面板快捷键 */
setOpenMainPanelShortcut(shortcut: string) {
if (!this.shortcuts) {
this.shortcuts = { screenshot: 'CmdOrCtrl+Alt+H', openMainPanel: 'CmdOrCtrl+Alt+P' }
}
this.shortcuts.openMainPanel = shortcut
},
/** 设置发送消息快捷键 */
setSendMessageShortcut(shortcut: string): void {
if (!this.chat) {
this.chat = { sendKey: 'Enter', isDouble: true, translate: 'tencent' }
}
this.chat.sendKey = shortcut
}
},
share: {

View File

@@ -70,7 +70,7 @@ export const useVideoViewer = defineStore(
// 更新视频列表中的特定视频路径(用于下载完成后更新为本地路径)
const updateVideoPath = (originalUrl: string, newPath: string) => {
const index = videoList.value.findIndex((url) => url === originalUrl)
const index = videoList.value.indexOf(originalUrl)
if (index !== -1) {
videoList.value[index] = newPath
}

View File

@@ -1144,7 +1144,7 @@ class VoiceMessageStrategyImpl extends AbstractMessageStrategy {
return {
type: MsgEnum.VOICE,
url: assetUrl,
size: parseInt(lastVoiceDiv.dataset.size || '0'),
size: parseInt(lastVoiceDiv.dataset.size || '0', 10),
duration: parseFloat(lastVoiceDiv.dataset.duration || '0'),
filename: lastVoiceDiv.dataset.filename || 'voice.mp3'
}

View File

@@ -52,6 +52,13 @@ declare namespace STO {
/** 翻译提供商 */
translate: 'youdao' | 'tencent'
}
/** 快捷键设置 */
shortcuts: {
/** 截图快捷键 */
screenshot: string
/** 打开主面板快捷键 */
openMainPanel: string
}
/** 界面设置 */
page: {
/** 是否开启阴影 */

View File

@@ -284,7 +284,7 @@ export const getImageDimensions = async (
try {
// 获取远程图片大小
const response = await fetch(input, { method: 'HEAD' })
result.size = parseInt(response.headers.get('content-length') || '0')
result.size = parseInt(response.headers.get('content-length') || '0', 10)
} catch (_error) {
// 如果无法获取大小,使用默认值
result.size = 0

View File

@@ -123,6 +123,18 @@ const handleClickMsg = async (group: any) => {
// 取消状态栏闪烁
const handleTip = async () => {
globalStore.setTipVisible(false)
// 取消窗口置顶
await appWindow?.setAlwaysOnTop(false)
// 隐藏窗口
await appWindow?.hide()
// 清空消息内容
content.value = []
msgCount.value = 0
// 重置窗口高度
resizeWindow('notify', 280, 140)
}
const debouncedHandleTip = useDebounceFn(handleTip, 100)

View File

@@ -2,8 +2,7 @@
<!-- 通知样式窗口 (接收方且未接听) -->
<div
v-if="isReceiver && !isCallAccepted"
data-tauri-drag-region
class="w-360px h-100px bg-white dark:bg-gray-900 rounded-12px shadow-2xl border border-gray-200 dark:border-gray-700 flex items-center p-12px select-none backdrop-blur-md">
class="w-360px h-full bg-white dark:bg-gray-800 flex-y-center px-12px select-none">
<!-- 用户头像 -->
<div class="relative mr-12px">
<n-avatar
@@ -13,18 +12,16 @@
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
class="rounded-12px shadow-md" />
<!-- 通话类型指示器 -->
<div
class="absolute -bottom-2px -right-2px w-20px h-20px rounded-full bg-blue-500 flex items-center justify-center shadow-lg">
<Icon
:icon="callType === CallTypeEnum.VIDEO ? 'material-symbols:videocam' : 'material-symbols:call'"
:size="12"
class="text-white" />
<div class="absolute -bottom-2px -right-2px w-20px h-20px rounded-full bg-blue-500 flex-center shadow-lg">
<svg class="size-14px color-#fff">
<use :href="callType === CallTypeEnum.VIDEO ? '#video-one' : '#phone-telephone'"></use>
</svg>
</div>
</div>
<!-- 用户信息和状态 -->
<div class="flex-1 min-w-0">
<div class="text-15px font-semibold text-gray-900 dark:text-white mb-2px truncate">
<div class="text-15px font-semibold text-gray-900 dark:text-white mb-12px truncate">
{{ remoteUserInfo?.name || '未知用户' }}
</div>
<div class="text-12px text-gray-500 dark:text-gray-400 flex items-center">
@@ -34,18 +31,18 @@
</div>
<!-- 操作按钮 -->
<div class="flex gap-6px mr-7">
<div class="flex gap-16px mr-8">
<!-- 拒绝按钮 -->
<div
@click="rejectCall"
class="w-44px h-44px rounded-full bg-#d5304f hover:bg-#d5304f active:bg-#d5304f flex items-center justify-center cursor-pointer transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105 active:scale-95">
<Icon icon="material-symbols:call-end" :size="20" class="text-white" />
class="size-40px rounded-full bg-#d5304f hover:bg-#d5304f flex-center cursor-pointer shadow-lg">
<svg class="color-#fff size-20px"><use href="#PhoneHangup"></use></svg>
</div>
<!-- 接听按钮 -->
<div
@click="acceptCall"
class="w-44px h-44px rounded-full bg-#13987f hover:bg-#13987f active:bg-#13987f flex items-center justify-center cursor-pointer transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105 active:scale-95">
<Icon icon="material-symbols:call" :size="20" class="text-white" />
class="size-40px rounded-full bg-#13987f hover:bg-#13987f flex-center cursor-pointer shadow-lg">
<svg class="color-#fff size-20px"><use href="#phone-telephone-entity"></use></svg>
</div>
</div>
</div>
@@ -66,24 +63,23 @@
<!-- 窗口控制栏 -->
<ActionBar
ref="actionBarRef"
class="relative z-10"
:top-win-label="WebviewWindow.getCurrent().label"
:shrink="false"
:min-w="false"
:max-w="false" />
:shrink="false" />
<!-- 主要内容区域 -->
<div class="flex-1 flex flex-col items-center justify-center px-32px pt-60px relative z-10">
<div class="flex-1 flex-col-center px-8px pt-6px relative z-10 min-h-0">
<!-- 视频通话时显示视频 (只有在双方都开启视频时才显示) -->
<div
v-if="callType === CallTypeEnum.VIDEO && localStream && (isVideoEnabled || hasRemoteVideo)"
class="mb-32px relative">
class="w-full flex-1 pb-22px relative min-h-0">
<!-- 主视频 -->
<video
ref="mainVideoRef"
autoplay
playsinline
class="w-320px h-240px rounded-8px bg-black object-cover"></video>
class="w-full h-full scale-x-[-1] rounded-8px bg-black object-cover"></video>
<!-- 画中画视频 -->
<div class="absolute top-8px right-8px group">
@@ -92,12 +88,13 @@
v-if="isPipVideoVisible"
autoplay
playsinline
class="w-120px h-90px rounded-4px bg-black object-cover border-2 border-white cursor-pointer hover:border-blue-400 transition-colors"
:class="isWindowMaximized ? 'w-320px h-190px' : 'w-120px h-90px'"
class="scale-x-[-1] rounded-8px bg-black object-cover border-2 border-white cursor-pointer"
@click="toggleVideoLayout"></video>
<!-- 切换提示 -->
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black bg-opacity-30 rounded-4px pointer-events-none">
<Icon icon="material-symbols:swap-horiz" class="text-white text-20px" />
class="absolute inset-0 flex-center opacity-0 group-hover:opacity-100 transition-opacity bg-black bg-opacity-30 rounded-8px pointer-events-none">
<svg class="text-#fff size-20px"><use href="#switch"></use></svg>
</div>
</div>
</div>
@@ -129,57 +126,108 @@
</div>
<!-- 底部控制按钮 -->
<div class="call-controls flex flex-col items-center pb-40px relative z-10">
<!-- 上排按钮静音扬声器摄像头 -->
<div class="flex items-start justify-center gap-40px mb-32px">
<div class="relative z-10">
<!-- 视频通话最大化时的一行布局 -->
<div v-if="callType === CallTypeEnum.VIDEO && isWindowMaximized" class="pb-10px flex-center gap-32px">
<!-- 静音按钮 -->
<div class="flex flex-col items-center">
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleMute"
class="control-btn w-60px h-60px rounded-full flex items-center justify-center cursor-pointer transition-all duration-200 mb-8px"
class="size-44px rounded-full flex-center cursor-pointer"
:class="isMuted ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<Icon :icon="isMuted ? 'material-symbols:mic-off' : 'material-symbols:mic'" :size="24" class="text-white" />
<svg class="size-16px color-#fff"><use :href="isMuted ? '#voice-off' : '#voice'"></use></svg>
</div>
<div class="text-12px text-gray-400 whitespace-nowrap">{{ isMuted ? '无麦克风' : '麦克风' }}</div>
<div class="text-12px text-gray-400 text-center">{{ isMuted ? '无麦克风' : '麦克风' }}</div>
</div>
<!-- 扬声器按钮 -->
<div class="flex flex-col items-center">
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleSpeaker"
class="control-btn w-60px h-60px rounded-full flex items-center justify-center cursor-pointer transition-all duration-200 mb-8px"
:class="isSpeakerOn ? 'bg-blue-500 hover:bg-blue-400' : 'bg-gray-600 hover:bg-gray-500'">
<Icon
:icon="isSpeakerOn ? 'material-symbols:volume-up' : 'material-symbols:volume-down'"
:size="24"
class="text-white" />
class="size-44px rounded-full flex-center cursor-pointer"
:class="isSpeakerOn ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<svg class="size-16px color-#fff"><use :href="isSpeakerOn ? '#volume-mute' : '#volume-notice'"></use></svg>
</div>
<div class="text-12px text-gray-400 whitespace-nowrap">{{ isSpeakerOn ? '扬声器已开' : '扬声器' }}</div>
<div class="text-12px text-gray-400 text-center">{{ isSpeakerOn ? '扬声器已开' : '扬声器' }}</div>
</div>
<!-- 摄像头按钮 (仅视频通话时显示) -->
<div v-if="callType === CallTypeEnum.VIDEO" class="flex flex-col items-center">
<!-- 摄像头按钮 -->
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleVideo"
class="control-btn w-60px h-60px rounded-full flex items-center justify-center cursor-pointer transition-all duration-200 mb-8px"
:class="isVideoEnabled ? 'bg-blue-500 hover:bg-blue-400' : 'bg-#d5304f60 hover:bg-#d5304f80'">
<Icon
:icon="isVideoEnabled ? 'material-symbols:videocam' : 'material-symbols:videocam-off'"
:size="24"
class="text-white" />
class="size-44px rounded-full flex-center cursor-pointer"
:class="isVideoEnabled ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<svg class="size-16px color-#fff">
<use :href="isVideoEnabled ? '#video-one' : '#monitor-off'"></use>
</svg>
</div>
<div class="text-12px text-gray-400 whitespace-nowrap">
<div class="text-12px text-gray-400 text-center">
{{ isVideoEnabled ? '关闭摄像头' : '开启摄像头' }}
</div>
</div>
<!-- 挂断按钮 -->
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="handleCallResponse(CallResponseStatus.DROPPED)"
class="size-44px rounded-full bg-#d5304f60 hover:bg-#d5304f80 flex-center cursor-pointer">
<svg class="size-16px color-#fff"><use href="#PhoneHangup"></use></svg>
</div>
<div class="text-12px text-gray-400 text-center">挂断</div>
</div>
</div>
<!-- 下排按钮挂断 -->
<div class="flex justify-center">
<div
@click="handleCallResponse(CallResponseStatus.DROPPED)"
class="control-btn w-70px h-70px rounded-full bg-#d5304f60 hover:bg-#d5304f80 flex items-center justify-center cursor-pointer transition-all duration-200">
<Icon icon="material-symbols:call-end" :size="32" class="text-white" />
<!-- 非全屏时的两行布局 -->
<div v-else class="pb-30px flex-col-x-center">
<!-- 上排按钮静音扬声器摄像头 -->
<div class="flex-center gap-40px mb-32px">
<!-- 静音按钮 -->
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleMute"
class="size-44px rounded-full flex-center cursor-pointer"
:class="isMuted ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<svg class="size-16px color-#fff"><use :href="isMuted ? '#voice-off' : '#voice'"></use></svg>
</div>
<div class="text-12px text-gray-400 text-center">{{ isMuted ? '无麦克风' : '麦克风' }}</div>
</div>
<!-- 扬声器按钮 -->
<div class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleSpeaker"
class="size-44px rounded-full flex-center cursor-pointer"
:class="isSpeakerOn ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<svg class="size-16px color-#fff">
<use :href="isSpeakerOn ? '#volume-mute' : '#volume-notice'"></use>
</svg>
</div>
<div class="text-12px text-gray-400 text-center">{{ isSpeakerOn ? '扬声器已开' : '扬声器' }}</div>
</div>
<!-- 摄像头按钮 (仅视频通话时显示) -->
<div v-if="callType === CallTypeEnum.VIDEO" class="flex-col-x-center gap-8px w-80px">
<div
@click="toggleVideo"
class="size-44px rounded-full flex-center cursor-pointer"
:class="isVideoEnabled ? 'bg-#d5304f60 hover:bg-#d5304f80' : 'bg-gray-600 hover:bg-gray-500'">
<svg class="size-16px color-#fff">
<use :href="isVideoEnabled ? '#video-one' : '#monitor-off'"></use>
</svg>
</div>
<div class="text-12px text-gray-400 text-center">
{{ isVideoEnabled ? '关闭摄像头' : '开启摄像头' }}
</div>
</div>
</div>
<!-- 下排按钮挂断 -->
<div class="flex-x-center">
<div
@click="handleCallResponse(CallResponseStatus.DROPPED)"
class="size-66px rounded-full bg-#d5304f60 hover:bg-#d5304f80 flex-center cursor-pointer">
<svg class="size-24px color-#fff"><use href="#PhoneHangup"></use></svg>
</div>
</div>
</div>
</div>
@@ -189,24 +237,23 @@
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { primaryMonitor } from '@tauri-apps/api/window'
import { info } from '@tauri-apps/plugin-log'
import { type } from '@tauri-apps/plugin-os'
import { useRoute } from 'vue-router'
import ActionBar from '@/components/windows/ActionBar.vue'
import type ActionBar from '@/components/windows/ActionBar.vue'
import { CallTypeEnum, RTCCallStatus, ThemeEnum } from '@/enums'
import { useUserInfo } from '@/hooks/useCached'
import { useWebRtc } from '@/hooks/useWebRtc'
import { useSettingStore } from '@/stores/setting'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
import { CallResponseStatus } from '../../services/wsType'
const settingStore = useSettingStore()
const { themes } = storeToRefs(settingStore)
const avatarSrc = computed(() => AvatarUtils.getAvatarUrl(remoteUserInfo.value?.avatar as string))
// 通过路由参数获取数据
const route = useRoute()
const remoteUserId = route.query.remoteUserId as string
@@ -225,7 +272,9 @@ const {
toggleVideo: toggleVideoWebRtc,
isVideoEnabled,
pauseBell,
playBell
playBell,
stopBell,
startBell
} = useWebRtc(roomId, remoteUserId, callType, isReceiver)
const remoteAudioRef = ref<HTMLAudioElement>()
const isMuted = ref(false)
@@ -237,6 +286,8 @@ const remoteUserInfo = useUserInfo(remoteUserId)
// 视频元素引用
const mainVideoRef = ref<HTMLVideoElement>()
const pipVideoRef = ref<HTMLVideoElement>()
// ActionBar 组件引用
const actionBarRef = useTemplateRef<typeof ActionBar>('actionBarRef')
// 视频布局状态false=远程视频主画面true=本地视频主画面
const isLocalVideoMain = ref(true)
// 通话接听状态
@@ -249,18 +300,11 @@ console.log('当前操作系统:', currentOS)
const createSize = (width: number, height: number) => {
const size = currentOS === 'windows' ? new LogicalSize(width, height) : new PhysicalSize(width, height)
console.log(
`创建窗口大小 ${width}x${height}:`,
size,
`类型: ${currentOS === 'windows' ? 'LogicalSize' : 'PhysicalSize'}`
)
return size
}
const normalSize = createSize(500, 650)
const notificationSize = createSize(360, 100)
const avatarSrc = computed(() => AvatarUtils.getAvatarUrl(remoteUserInfo.value?.avatar as string))
// 计算属性
const callStatusText = computed(() => {
switch (connectionStatus.value) {
case RTCCallStatus.CALLING:
@@ -304,6 +348,11 @@ const isPipVideoVisible = computed(() => {
return (hasLocalVideo.value || hasRemoteVideo.value) && !!remoteStream.value
})
// 获取窗口最大化状态
const isWindowMaximized = computed(() => {
return actionBarRef.value?.windowMaximized
})
// 视频流分配工具函数
const assignVideoStreams = async () => {
await nextTick()
@@ -431,16 +480,26 @@ const toggleVideoLayout = async () => {
// 接听通话
const acceptCall = async () => {
// 立即停止铃声
stopBell()
isCallAccepted.value = true
// 调用接听响应函数
sendRtcCall2VideoCallResponse(1)
// 调整窗口大小为正常大小
// 调整窗口大小为正常通话大小
try {
const currentWindow = WebviewWindow.getCurrent()
await currentWindow.setSize(normalSize)
await currentWindow.setSize(createSize(500, 650))
await currentWindow.center()
// 取消窗口置顶
await currentWindow.setAlwaysOnTop(false)
// 恢复标题栏按钮显示
if (currentOS === 'macos') {
await invokeSilently('show_title_bar_buttons', { windowLabel: currentWindow.label })
}
// 确保窗口获得焦点
try {
await currentWindow.setFocus()
@@ -454,6 +513,8 @@ const acceptCall = async () => {
// 拒绝通话
const rejectCall = async () => {
// 立即停止铃声
stopBell()
// 调用拒绝响应函数
handleCallResponse(CallResponseStatus.REJECTED)
}
@@ -499,71 +560,63 @@ onMounted(async () => {
}
})
try {
if (isReceiver && !isCallAccepted.value) {
// 设置通知窗口大小
await currentWindow.setSize(notificationSize)
if (isReceiver && !isCallAccepted.value) {
// 接收方立即开始播放铃声
startBell()
// 获取屏幕尺寸并定位到右下角
try {
const monitor = await primaryMonitor()
if (monitor) {
const margin = 20
const taskbarHeight = 80 // Windows任务栏高度
// 设置来电通知窗口的正确大小和位置
await currentWindow.setSize(createSize(360, 90))
let screenWidth: number
let screenHeight: number
let x: number
let y: number
// 隐藏标题栏和设置窗口不可移动
if (currentOS === 'macos') {
await invokeSilently('hide_title_bar_buttons', { windowLabel: currentWindow.label, hideCloseButton: true })
}
if (currentOS === 'windows') {
// Windows使用逻辑像素进行计算
screenWidth = monitor.size.width / (monitor.scaleFactor || 1)
screenHeight = monitor.size.height / (monitor.scaleFactor || 1)
x = Math.max(0, screenWidth - notificationSize.width - margin)
y = Math.max(0, screenHeight - notificationSize.height - margin - taskbarHeight)
await currentWindow.setPosition(new LogicalPosition(x, y))
} else {
// Mac使用物理像素进行计算
screenWidth = monitor.size.width
screenHeight = monitor.size.height
x = Math.max(0, screenWidth - notificationSize.width - margin)
y = Math.max(0, screenHeight - notificationSize.height - margin - taskbarHeight)
await currentWindow.setPosition(new PhysicalPosition(x, y))
}
} else {
// 如果无法获取主显示器信息,使用屏幕右下角的估算位置
await currentWindow.setPosition(new LogicalPosition(800, 600))
}
} catch (error) {
console.error('Failed to get monitor info:', error)
// 如果获取屏幕信息失败,使用更安全的默认位置
await currentWindow.setPosition(new LogicalPosition(800, 600))
// 获取屏幕尺寸并定位
const monitor = await primaryMonitor()
if (monitor) {
const margin = 20
const taskbarHeight = 40
let screenWidth: number
let screenHeight: number
let x: number
let y: number
if (currentOS === 'windows') {
// Windows使用逻辑像素进行计算窗口在右下角
screenWidth = monitor.size.width / (monitor.scaleFactor || 1)
screenHeight = monitor.size.height / (monitor.scaleFactor || 1)
x = Math.max(0, screenWidth - 360 - margin)
y = Math.max(0, screenHeight - 90 - margin - taskbarHeight)
await currentWindow.setPosition(new LogicalPosition(x, y))
} else {
// Mac使用物理像素进行计算窗口在右上角
screenWidth = monitor.size.width
screenHeight = monitor.size.height
x = Math.max(0, screenWidth - 360 - margin)
y = margin
await currentWindow.setPosition(new PhysicalPosition(x, y))
}
} else {
// 正常大小窗口
await currentWindow.setSize(normalSize)
await currentWindow.center()
// 如果无法获取主显示器信息,使用屏幕右下角的估算位置
await currentWindow.setPosition(new LogicalPosition(800, 600))
}
await currentWindow.setAlwaysOnTop(true)
} else {
// 正常通话窗口设置
await currentWindow.center()
await currentWindow.setAlwaysOnTop(false)
// 确保窗口显示
await currentWindow.show()
// 在Windows上有时需要额外的焦点设置
try {
await currentWindow.setFocus()
} catch (error) {
console.warn('Failed to set window focus:', error)
}
} catch (error) {
console.error('Failed to setup window:', error)
// 最后的fallback至少尝试显示窗口
try {
await currentWindow.show()
} catch (showError) {
console.error('Failed to show window:', showError)
// 确保标题栏按钮显示(非来电通知状态)
if (currentOS === 'macos') {
await invokeSilently('show_title_bar_buttons', { windowLabel: currentWindow.label })
}
}
// 确保窗口显示
await currentWindow.show()
await currentWindow.setFocus()
})
</script>

View File

@@ -63,19 +63,6 @@
<span class="pl-10px">聊天</span>
<n-flex class="item" :size="12" vertical>
<!-- 发送信息 -->
<n-flex align="center" justify="space-between">
<span>发送信息快捷键</span>
<n-select
class="w-140px"
size="small"
label-field="value"
v-model:value="chat.sendKey"
:options="sendOptions" />
</n-flex>
<span class="w-full h-1px bg-[--line-color]"></span>
<!-- 双击打开独立会话 -->
<!-- <n-flex align="center" justify="space-between">
<span>双击会话列表打开独立聊天窗口</span>
@@ -158,7 +145,7 @@ import { type } from '@tauri-apps/plugin-os'
import { NSwitch } from 'naive-ui'
import { CloseBxEnum, ShowModeEnum } from '@/enums'
import { useSettingStore } from '@/stores/setting.ts'
import { fontOptions, sendOptions, translateOptions } from './config.ts'
import { fontOptions, translateOptions } from './config.ts'
import { topicsList } from './model.tsx'
const appWindow = WebviewWindow.getCurrent()

View File

@@ -0,0 +1,411 @@
<template>
<n-flex vertical :size="40">
<!-- 截图快捷键设置 -->
<n-flex vertical class="text-(14px [--text-color])" :size="16">
<span class="pl-10px">截图快捷键</span>
<n-flex class="item" :size="12" vertical>
<!-- 截图快捷键 -->
<n-flex align="center" justify="space-between">
<n-flex vertical :size="8">
<span>{{ shortcutConfigs.screenshot.displayName }}</span>
<span class="text-(12px #909090)">按下快捷键即可开始截图</span>
</n-flex>
<n-flex align="center" :size="12">
<n-tag v-if="shortcutRegistered !== null" :type="shortcutRegistered ? 'success' : 'error'" size="small">
{{ shortcutRegistered ? '已绑定' : '未绑定' }}
</n-tag>
<n-input
:value="screenshotShortcutDisplay"
:placeholder="screenshotShortcutDisplay"
style="width: 130px"
class="border-(1px solid #90909080)"
readonly
size="small"
@keydown="handleShortcutInput"
@focus="handleScreenshotFocus"
@blur="handleScreenshotBlur">
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<svg @click="resetScreenshotShortcut" class="size-14px cursor-pointer">
<use href="#return"></use>
</svg>
</template>
<span>重置</span>
</n-tooltip>
</template>
</n-input>
</n-flex>
</n-flex>
<span class="w-full h-1px bg-[--line-color]"></span>
<!-- 打开主面板快捷键 -->
<n-flex align="center" justify="space-between">
<n-flex vertical :size="8">
<span>{{ shortcutConfigs.openMainPanel.displayName }}</span>
<span class="text-(12px #909090)">按下快捷键即可切换主面板显示状态</span>
</n-flex>
<n-flex align="center" :size="12">
<n-tag
v-if="openMainPanelShortcutRegistered !== null"
:type="openMainPanelShortcutRegistered ? 'success' : 'error'"
size="small">
{{ openMainPanelShortcutRegistered ? '已绑定' : '未绑定' }}
</n-tag>
<n-input
:value="openMainPanelShortcutDisplay"
:placeholder="openMainPanelShortcutDisplay"
style="width: 130px"
class="border-(1px solid #90909080)"
readonly
size="small"
@keydown="handleOpenMainPanelShortcutInput"
@focus="handleOpenMainPanelFocus"
@blur="handleOpenMainPanelBlur">
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<svg @click="resetOpenMainPanelShortcut" class="size-14px cursor-pointer">
<use href="#return"></use>
</svg>
</template>
<span>重置</span>
</n-tooltip>
</template>
</n-input>
</n-flex>
</n-flex>
<span class="w-full h-1px bg-[--line-color]"></span>
<!-- 发送消息快捷键 -->
<n-flex align="center" justify="space-between">
<n-flex vertical :size="8">
<span>发送消息快捷键</span>
<span class="text-(12px #909090)">在聊天输入框中按下快捷键发送消息</span>
</n-flex>
<n-flex align="center" :size="12">
<n-select
v-model:value="sendMessageShortcut"
class="w-200px"
size="small"
label-field="label"
:options="sendOptions"
@blur="handleSendMessageBlur" />
</n-flex>
</n-flex>
</n-flex>
</n-flex>
</n-flex>
</template>
<script setup lang="ts">
import { emit, listen } from '@tauri-apps/api/event'
import { isRegistered } from '@tauri-apps/plugin-global-shortcut'
import { type } from '@tauri-apps/plugin-os'
import { useMessage } from 'naive-ui'
import { useSettingStore } from '@/stores/setting.ts'
import { sendOptions } from './config.ts'
const message = useMessage()
const settingStore = useSettingStore()
// 快捷键配置管理
type ShortcutConfig = {
key: 'screenshot' | 'openMainPanel'
value: Ref<string>
isCapturing: Ref<boolean>
isRegistered: Ref<boolean | null>
original: Ref<string>
defaultValue: string
eventName: string
registrationEventName: string
displayName: string
}
// 统一的快捷键配置
const shortcutConfigs: Record<string, ShortcutConfig> = {
screenshot: {
key: 'screenshot',
value: ref(settingStore.shortcuts?.screenshot),
isCapturing: ref(false),
isRegistered: ref<boolean | null>(null),
original: ref(settingStore.shortcuts?.screenshot),
defaultValue: 'CmdOrCtrl+Alt+H',
eventName: 'shortcut-updated',
registrationEventName: 'shortcut-registration-updated',
displayName: '截图快捷键'
},
openMainPanel: {
key: 'openMainPanel',
value: ref(settingStore.shortcuts?.openMainPanel),
isCapturing: ref(false),
isRegistered: ref<boolean | null>(null),
original: ref(settingStore.shortcuts?.openMainPanel),
defaultValue: 'CmdOrCtrl+Alt+P',
eventName: 'open-main-panel-shortcut-updated',
registrationEventName: 'open-main-panel-shortcut-registration-updated',
displayName: '切换主面板快捷键'
}
}
// 向后兼容的别名(仅保留模板中使用的)
const screenshotShortcut = shortcutConfigs.screenshot.value
const openMainPanelShortcut = shortcutConfigs.openMainPanel.value
const shortcutRegistered = shortcutConfigs.screenshot.isRegistered
const openMainPanelShortcutRegistered = shortcutConfigs.openMainPanel.isRegistered
// 发送消息快捷键单独处理
const sendMessageShortcut = ref(settingStore.chat?.sendKey)
// 将快捷键转换为平台对应的显示文本
const formatShortcutDisplay = (shortcut: string) => {
const isWindows = type() === 'windows'
return shortcut
.replace('CmdOrCtrl', isWindows ? 'Ctrl' : 'Command')
.replace('Cmd', 'Command')
.split('+')
.map((key) => key.trim())
.map((key) => {
// 将按键名称格式化为小写(除了特殊键)
if (['Ctrl', 'Command', 'Alt', 'Shift'].includes(key)) {
return key.toLowerCase()
}
return key.toLowerCase()
})
.join(' + ')
}
// 输入框显示的快捷键文本(用于显示,不用于绑定)
const screenshotShortcutDisplay = computed(() => {
return formatShortcutDisplay(screenshotShortcut.value)
})
const openMainPanelShortcutDisplay = computed(() => {
return formatShortcutDisplay(openMainPanelShortcut.value)
})
// 通用的store变化监听
const createShortcutWatcher = (config: ShortcutConfig, storeGetter: () => string | undefined) => {
return watch(
storeGetter,
(newValue) => {
if (newValue && !config.isCapturing.value) {
config.value.value = newValue
config.original.value = newValue
}
},
{ immediate: true }
)
}
// 监听 store 变化,确保数据同步
createShortcutWatcher(shortcutConfigs.screenshot, () => settingStore.shortcuts?.screenshot)
createShortcutWatcher(shortcutConfigs.openMainPanel, () => settingStore.shortcuts?.openMainPanel)
watch(
() => settingStore.chat?.sendKey,
(newValue) => {
if (newValue) {
sendMessageShortcut.value = newValue
}
},
{ immediate: true }
)
// 通用的快捷键绑定检查
const checkShortcutRegistration = async (config: ShortcutConfig) => {
config.isRegistered.value = await isRegistered(config.value.value)
}
// 通用的快捷键输入处理
const createShortcutInputHandler = (config: ShortcutConfig) => {
return (event: KeyboardEvent) => {
if (!config.isCapturing.value) return
event.preventDefault()
event.stopPropagation()
const keys: string[] = []
// 检查修饰键 - 统一使用CmdOrCtrl以保证跨平台兼容性
if (event.ctrlKey || event.metaKey) {
keys.push('CmdOrCtrl')
}
if (event.altKey) {
keys.push('Alt')
}
if (event.shiftKey) {
keys.push('Shift')
}
// 获取主键
const mainKey = event.key
if (mainKey && !['Control', 'Alt', 'Shift', 'Meta', 'Cmd'].includes(mainKey)) {
keys.push(mainKey.toUpperCase())
}
// 至少需要一个修饰键和一个主键
if (keys.length >= 2) {
// 更新内部存储值使用CmdOrCtrl格式显示会通过computed自动格式化
config.value.value = keys.join('+')
}
}
}
// 创建具体的处理函数
const handleShortcutInput = createShortcutInputHandler(shortcutConfigs.screenshot)
const handleOpenMainPanelShortcutInput = createShortcutInputHandler(shortcutConfigs.openMainPanel)
// 通用的焦点处理
const createFocusHandler = (config: ShortcutConfig) => {
return async () => {
config.isCapturing.value = true
config.original.value = config.value.value
console.log(`🎯 开始编辑${config.displayName}`)
}
}
const createBlurHandler = (config: ShortcutConfig, saveFunction: () => Promise<void>) => {
return async () => {
config.isCapturing.value = false
console.log(`✅ 结束编辑${config.displayName}`)
// 如果快捷键有变化,则保存
if (config.value.value !== config.original.value) {
await saveFunction()
}
}
}
// 创建具体的焦点处理函数
const handleScreenshotFocus = createFocusHandler(shortcutConfigs.screenshot)
const handleOpenMainPanelFocus = createFocusHandler(shortcutConfigs.openMainPanel)
// 处理发送消息快捷键失去焦点事件(自动保存)
const handleSendMessageBlur = async () => {
// 如果快捷键有变化,则保存
const currentSendKey = settingStore.chat?.sendKey || 'Enter'
if (sendMessageShortcut.value !== currentSendKey) {
await saveSendMessageShortcut()
}
}
// 通用的保存快捷键方法
const createSaveShortcutFunction = (config: ShortcutConfig) => {
return async () => {
try {
console.log(`💾 [Settings] 开始保存${config.displayName}: ${config.value.value}`)
// 根据快捷键类型调用对应的store方法
if (config.key === 'screenshot') {
settingStore.setScreenshotShortcut(config.value.value)
} else if (config.key === 'openMainPanel') {
settingStore.setOpenMainPanelShortcut(config.value.value)
}
config.original.value = config.value.value
console.log(`💾 [Settings] 已保存到 Pinia store`)
// 通知主窗口更新快捷键(跨窗口事件)
console.log(`📡 [Settings] 发送 ${config.eventName} 事件到主窗口`)
await emit(config.eventName, { shortcut: config.value.value })
console.log(`📡 [Settings] ${config.eventName} 事件已发送`)
message.success(`${config.displayName}已更新`)
} catch (error) {
console.error(`Failed to save ${config.key} shortcut:`, error)
message.error(`${config.displayName}设置失败`)
// 恢复原来的快捷键
config.value.value = config.original.value
}
}
}
// 通用的重置快捷键方法
const createResetShortcutFunction = (config: ShortcutConfig, saveFunction: () => Promise<void>) => {
return async () => {
config.value.value = config.defaultValue
await saveFunction()
}
}
// 创建具体的保存函数
const saveScreenshotShortcut = createSaveShortcutFunction(shortcutConfigs.screenshot)
const saveOpenMainPanelShortcut = createSaveShortcutFunction(shortcutConfigs.openMainPanel)
// 创建具体的重置函数
const resetScreenshotShortcut = createResetShortcutFunction(shortcutConfigs.screenshot, saveScreenshotShortcut)
const resetOpenMainPanelShortcut = createResetShortcutFunction(shortcutConfigs.openMainPanel, saveOpenMainPanelShortcut)
// 创建失焦处理函数
const handleScreenshotBlur = createBlurHandler(shortcutConfigs.screenshot, saveScreenshotShortcut)
const handleOpenMainPanelBlur = createBlurHandler(shortcutConfigs.openMainPanel, saveOpenMainPanelShortcut)
// 保存发送消息快捷键设置
const saveSendMessageShortcut = async () => {
try {
// 保存到 pinia store
settingStore.setSendMessageShortcut(sendMessageShortcut.value)
message.success('发送消息快捷键已更新')
} catch (error) {
console.error('Failed to save send message shortcut:', error)
message.error('发送消息快捷键设置失败')
// 恢复原来的值
sendMessageShortcut.value = settingStore.chat?.sendKey || 'Enter'
}
}
// 通用的事件监听器创建
const createRegistrationListener = (config: ShortcutConfig) => {
return listen(config.registrationEventName, (event: any) => {
const { shortcut, registered } = event.payload
console.log(`📡 [Settings] 收到${config.displayName}状态更新: ${shortcut} -> ${registered ? '已绑定' : '未绑定'}`)
// 只有当前快捷键匹配时才更新状态
if (shortcut === config.value.value) {
console.log(`📡 [Settings] ${config.displayName}匹配,更新状态为: ${registered ? '已绑定' : '未绑定'}`)
config.isRegistered.value = registered
} else {
console.log(`📡 [Settings] ${config.displayName}不匹配,忽略状态更新`)
}
})
}
onMounted(async () => {
// 检查所有快捷键的绑定状态
await Promise.all([
checkShortcutRegistration(shortcutConfigs.screenshot),
checkShortcutRegistration(shortcutConfigs.openMainPanel)
])
// 创建事件监听器
const unlistenScreenshot = await createRegistrationListener(shortcutConfigs.screenshot)
const unlistenOpenMainPanel = await createRegistrationListener(shortcutConfigs.openMainPanel)
// 组件卸载时取消监听
onUnmounted(() => {
unlistenScreenshot()
unlistenOpenMainPanel()
})
})
</script>
<style scoped lang="scss">
.item {
@apply bg-[--bg-setting-item] rounded-12px size-full p-12px box-border border-(solid 1px [--line-color]) custom-shadow;
}
:deep(.n-input.n-input--focus) {
border-width: 2px;
border-color: #13987f !important;
}
</style>

View File

@@ -16,6 +16,11 @@ const sideOptions = ref<OPT.L.SettingSide[]>([
label: '存储管理',
icon: 'mini-sd-card'
},
{
url: '/shortcut',
label: '快捷键管理',
icon: 'enter-the-keyboard'
},
{
url: '/loginSetting',
label: '登录设置',

View File

@@ -275,7 +275,7 @@ const previousVideo = async () => {
// 如果是本地视频,从文件夹获取视频列表
const folderVideos = await getVideosFromCurrentFolder(currentVideoPath)
if (folderVideos.length > 0) {
const currentVideoIndex = folderVideos.findIndex((path) => path === currentVideoPath)
const currentVideoIndex = folderVideos.indexOf(currentVideoPath)
if (currentVideoIndex > 0) {
const previousVideoPath = folderVideos[currentVideoIndex - 1]
videoList.value[currentIndex.value] = previousVideoPath
@@ -323,7 +323,7 @@ const nextVideo = async () => {
// 如果是本地视频,从文件夹获取视频列表
const folderVideos = await getVideosFromCurrentFolder(currentVideoPath)
if (folderVideos.length > 0) {
const currentVideoIndex = folderVideos.findIndex((path) => path === currentVideoPath)
const currentVideoIndex = folderVideos.indexOf(currentVideoPath)
if (currentVideoIndex < folderVideos.length - 1) {
const nextVideoPath = folderVideos[currentVideoIndex + 1]
videoList.value[currentIndex.value] = nextVideoPath