Compare commits

...

34 Commits

Author SHA1 Message Date
卡仔
3de3827103 fix(mobile): 🐛 fixed missing chat room name display issue 2025-09-02 15:07:44 +08:00
卡仔
db2fc41a67 fix(mobile): 🐛 fix blank screen issue caused by routing 2025-09-02 12:05:20 +08:00
卡仔
c471ca7bd0 feat(mobile): added 'Add Group Chat' page and 'Add Friend' page 2025-09-01 18:31:54 +08:00
Dawn
4f37f9d928 build(build): 📦 update tauri version 2025-08-29 17:08:21 +08:00
Dawn
c8f22bda95 perf(mobile): horizontal screen prohibited 2025-08-27 18:55:12 +08:00
Dawn
bfe2c36756 perf(ios): compatible with some iOS styles 2025-08-27 16:52:46 +08:00
卡仔
092fdec8a3 fix(mobile): 🐛 fix scrolling issue when keyboard is focused 2025-08-26 19:47:49 +08:00
卡仔
0e69f59c44 feat(mobile): add avatar display to simplebio.vue page 2025-08-26 14:46:20 +08:00
卡仔
894d67d426 feat(mobile): add personal information display 2025-08-25 18:44:11 +08:00
卡仔
e8cdf48771 Merge branch 'feat/mobile/msg-api' into feat/mobile/ws-api 2025-08-25 17:12:51 +08:00
卡仔
01ccea9542 fix(mobile): 🐛 fix mobile routing error 2025-08-25 16:38:04 +08:00
卡仔
3c2708c9d7 fix(mobile): 🐛 fix inability to listen to WebSocket messages 2025-08-25 16:23:41 +08:00
卡仔
6b9e4072cb feat(mobile): add login and logout logic; implement route guards 2025-08-22 18:50:36 +08:00
卡仔
a870bd12b9 Merge branch 'fix/ql' into feat/mobile/msg-api 2025-08-22 18:47:24 +08:00
wanwanruwoxin
192a3bb12b fix(common): 🐛 add token relation command 2025-08-22 17:13:17 +08:00
wanwanruwoxin
7dfbab5ee9 fix(common): 🐛 fix send video msg 2025-08-22 16:54:16 +08:00
wanwanruwoxin
a4f658df02 fix(common): 🐛 fix recall msg bug 2025-08-22 15:32:23 +08:00
wanwanruwoxin
37453b4f4b fix(common): 🐛 fix getBadgesBatch bug 2025-08-22 11:08:27 +08:00
wanwanruwoxin
debca1370f fix(common): 🐛 handle 401 error; 2025-08-22 10:37:34 +08:00
wanwanruwoxin
55316b1175 fix(common): 🐛 fix getUserInfoBatch request bug; add emoji in log; 2025-08-22 10:00:32 +08:00
wanwanruwoxin
fd17bb17a4 Merge branch 'master' into fix/ql 2025-08-22 09:35:08 +08:00
wanwanruwoxin
5b86b0e7f4 fix(common): 🐛 fix date formate in announcement 2025-08-21 19:03:13 +08:00
卡仔
394b29b244 feat(mobile): add real-time message push 2025-08-21 18:54:26 +08:00
wanwanruwoxin
9c3111850d fix(common): 🐛 init msg list 2025-08-21 18:52:46 +08:00
wanwanruwoxin
4ece9d6efe fix(common): 🐛 logout 2025-08-21 18:21:44 +08:00
wanwanruwoxin
5b0ec76a8a refactor(common): ♻️ refactor all request to rust 2025-08-21 18:19:54 +08:00
wanwanruwoxin
a376a25ff4 fix(common): 🐛 getFriendPage invoke error 2025-08-21 16:00:02 +08:00
wanwanruwoxin
3b3d5a156a refactor(common): ♻️ refactor ws; fix error 2025-08-21 15:49:51 +08:00
wanwanruwoxin
de42e142a3 refactor(common): ♻️ refactor rust request 2025-08-21 10:18:38 +08:00
wanwanruwoxin
a3207f81be refactor(common): ♻️ extract ImrequestUtils in frontend 2025-08-21 09:06:45 +08:00
wanwanruwoxin
05138f2116 refactor(common): ♻️ refactor request by rust --> initial completion 2025-08-20 19:22:53 +08:00
卡仔
88a3a3a000 feat(mobile): add navigation from home avatar to SimpleBio page 2025-08-20 18:43:53 +08:00
wanwanruwoxin
3553576bae refactor(common): ♻️ refactor request by rust 2025-08-20 17:31:06 +08:00
wanwanruwoxin
40db445b1a fix(common): 🐛 apply issue 2025-08-20 13:27:42 +08:00
43 changed files with 1770 additions and 540 deletions

2
index.html vendored
View File

@@ -5,7 +5,7 @@
<title>HuLa</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no viewport-fit=cover" />
content="width=device-width, initial-scale=1, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- 🎯 预加载图片资源(仅移动端) -->
<script>

View File

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

89
src-tauri/Cargo.lock generated
View File

@@ -4317,6 +4317,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a"
dependencies = [
"objc2 0.6.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-metal"
version = "0.2.2"
@@ -4365,6 +4375,17 @@ dependencies = [
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-security"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44"
dependencies = [
"bitflags 2.9.2",
"objc2 0.6.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.1"
@@ -4389,6 +4410,8 @@ dependencies = [
"objc2-app-kit 0.3.1",
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -6260,9 +6283,9 @@ dependencies = [
[[package]]
name = "serialize-to-javascript"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb"
checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
dependencies = [
"serde",
"serde_json",
@@ -6271,13 +6294,13 @@ dependencies = [
[[package]]
name = "serialize-to-javascript-impl"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763"
checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"syn 2.0.106",
]
[[package]]
@@ -7068,12 +7091,13 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.7.0"
version = "2.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "352a4bc7bf6c25f5624227e3641adf475a6535707451b09bb83271df8b7a6ac7"
checksum = "5d545ccf7b60dcd44e07c6fb5aeb09140966f0aabd5d2aa14a6821df7bc99348"
dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs 6.0.0",
"dunce",
"embed_plist",
@@ -7093,6 +7117,7 @@ dependencies = [
"objc2-app-kit 0.3.1",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"objc2-web-kit",
"percent-encoding",
"plist",
"raw-window-handle",
@@ -7120,9 +7145,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.3.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "182d688496c06bf08ea896459bf483eb29cdff35c1c4c115fb14053514303064"
checksum = "67945dbaf8920dbe3a1e56721a419a0c3d085254ab24cff5b9ad55e2b0016e0b"
dependencies = [
"anyhow",
"cargo_toml",
@@ -7136,15 +7161,15 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.8.2",
"toml 0.9.5",
"walkdir",
]
[[package]]
name = "tauri-codegen"
version = "2.3.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b54a99a6cd8e01abcfa61508177e6096a4fe2681efecee9214e962f2f073ae4a"
checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -7169,9 +7194,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.3.2"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7945b14dc45e23532f2ded6e120170bbdd4af5ceaa45784a6b33d250fbce3f9e"
checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -7183,9 +7208,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.3.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bd5c1e56990c70a906ef67a9851bbdba9136d26075ee9a2b19c8b46986b3e02"
checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f"
dependencies = [
"anyhow",
"glob",
@@ -7194,7 +7219,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.8.2",
"toml 0.9.5",
"walkdir",
]
@@ -7411,9 +7436,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-os"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05bccb4c6de4299beec5a9b070878a01bce9e2c945aa7a75bcea38bcba4c675d"
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
dependencies = [
"gethostname 1.0.2",
"log",
@@ -7472,9 +7497,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.2"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e5a4ce43cb3a733c3aef85e8478bc769dac743c615e26639cbf5d953faf7"
checksum = "236043404a4d1502ed7cce11a8ec88ea1e85597eec9887b4701bb10b66b13b6e"
dependencies = [
"serde",
"serde_json",
@@ -7557,9 +7582,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.7.1"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b1cc885be806ea15ff7b0eb47098a7b16323d9228876afda329e34e2d6c4676"
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
dependencies = [
"cookie",
"dpi",
@@ -7568,20 +7593,23 @@ dependencies = [
"jni",
"objc2 0.6.2",
"objc2-ui-kit",
"objc2-web-kit",
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.15",
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.7.2"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe653a2fbbef19fe898efc774bc52c8742576342a33d3d028c189b57eb1d2439"
checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807"
dependencies = [
"gtk",
"http 1.3.1",
@@ -7606,9 +7634,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330c15cabfe1d9f213478c9e8ec2b0c76dab26bb6f314b8ad1c8a568c1d186e"
checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212"
dependencies = [
"anyhow",
"brotli",
@@ -7635,7 +7663,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.15",
"toml 0.8.2",
"toml 0.9.5",
"url",
"urlpattern",
"uuid",
@@ -9590,14 +9618,15 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "wry"
version = "0.52.1"
version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a714d9ba7075aae04a6e50229d6109e3d584774b99a6a8c60de1698ca111b9"
checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90"
dependencies = [
"base64 0.22.1",
"block2 0.6.1",
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",

View File

@@ -31,11 +31,11 @@ name = "hula_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.3.1", features = [] }
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
# Tauri 官方依赖
tauri = { version = "2.7.0", features = [
tauri = { version = "2.8.4", features = [
"protocol-asset",
"macos-private-api",
"tray-icon",
@@ -109,7 +109,7 @@ tauri-plugin-barcode-scanner = "2.4.0"
# 不兼容移动端的依赖
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2.3.2"
tauri-plugin-single-instance = "2.3.3"
tauri-plugin-updater = "2"
screenshots = "0.8.10"

View File

@@ -10,5 +10,14 @@
<string>HuLa needs screen recording permission to enable screenshot functionality</string>
<key>NSAccessibilityUsageDescription</key>
<string>HuLa needs accessibility permission to capture screen content</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
</dict>
</plist>

View File

@@ -17,7 +17,8 @@
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.hula">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,130 +1,79 @@
package com.hula_ios.app
import android.graphics.Rect
import android.os.Bundle
import android.webkit.WebView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import android.graphics.Rect
import android.view.ViewTreeObserver
import kotlin.math.roundToInt
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Enable edge-to-edge display for SafeArea support
// 全屏 Edge-to-Edge
WindowCompat.setDecorFitsSystemWindows(window, false)
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
// Apply window insets for SafeArea support
// 初始化 WebView 背景填充
// webView.setBackgroundColor(0x00000000) // 透明,父布局背景可见
// 监听安全区 Insets 并注入 CSS 变量
ViewCompat.setOnApplyWindowInsetsListener(webView) { _, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
// Calculate safe area insets
val safeAreaTop = maxOf(systemBars.top, displayCutout.top)
val safeAreaBottom = maxOf(systemBars.bottom, displayCutout.bottom)
val safeAreaLeft = maxOf(systemBars.left, displayCutout.left)
val safeAreaRight = maxOf(systemBars.right, displayCutout.right)
// Convert dp to px for CSS
val density = resources.displayMetrics.density
val topPx = (safeAreaTop / density).toInt()
val bottomPx = (safeAreaBottom / density).toInt()
val leftPx = (safeAreaLeft / density).toInt()
val rightPx = (safeAreaRight / density).toInt()
// Inject CSS custom properties for safe area insets
val safeTop = maxOf(systemBars.top, displayCutout.top)
val safeBottom = maxOf(systemBars.bottom, displayCutout.bottom)
val safeLeft = maxOf(systemBars.left, displayCutout.left)
val safeRight = maxOf(systemBars.right, displayCutout.right)
val script = """
document.documentElement.style.setProperty('--safe-area-inset-top', '${topPx}px');
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottomPx}px');
document.documentElement.style.setProperty('--safe-area-inset-left', '${leftPx}px');
document.documentElement.style.setProperty('--safe-area-inset-right', '${rightPx}px');
if (CSS && CSS.registerProperty) {
try {
CSS.registerProperty({ name: '--safe-area-inset-top', syntax: '<length>', inherits: true, initialValue: '${topPx}px' });
CSS.registerProperty({ name: '--safe-area-inset-bottom', syntax: '<length>', inherits: true, initialValue: '${bottomPx}px' });
CSS.registerProperty({ name: '--safe-area-inset-left', syntax: '<length>', inherits: true, initialValue: '${leftPx}px' });
CSS.registerProperty({ name: '--safe-area-inset-right', syntax: '<length>', inherits: true, initialValue: '${rightPx}px' });
} catch (e) {}
}
window.dispatchEvent(new CustomEvent('safeAreaChanged', {
detail: { top: ${topPx}, bottom: ${bottomPx}, left: ${leftPx}, right: ${rightPx} }
}));
document.documentElement.style.setProperty('--safe-area-inset-top', '${(safeTop/density).roundToInt()}px');
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${(safeBottom/density).roundToInt()}px');
document.documentElement.style.setProperty('--safe-area-inset-left', '${(safeLeft/density).roundToInt()}px');
document.documentElement.style.setProperty('--safe-area-inset-right', '${(safeRight/density).roundToInt()}px');
""".trimIndent()
webView.evaluateJavascript(script, null)
insets
}
// Add keyboard visibility listener
webView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
private var lastHeightDiff = 0
private var isKeyboardVisible = false
// 监听键盘弹出,并注入 CSS 变量 + 触发 JS 事件
ViewCompat.setOnApplyWindowInsetsListener(webView) { _, insets ->
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val density = resources.displayMetrics.density
val heightDp = (imeHeight / density).roundToInt()
override fun onGlobalLayout() {
val rect = Rect()
webView.getWindowVisibleDisplayFrame(rect)
val screenHeightPx = webView.rootView.height
val visibleHeightPx = rect.bottom
val heightDiffPx = screenHeightPx - visibleHeightPx
val density = webView.resources.displayMetrics.density
val screenHeight = (screenHeightPx / density).roundToInt()
val visibleHeight = (visibleHeightPx / density).roundToInt()
val heightDiff = (heightDiffPx / density).roundToInt()
val now = System.currentTimeMillis()
if (heightDiffPx > screenHeightPx * 0.15) {
if (!isKeyboardVisible || heightDiffPx != lastHeightDiff) {
isKeyboardVisible = true
lastHeightDiff = heightDiffPx
val script = """
window.dispatchEvent(new CustomEvent('keyboardDidShow', {
detail: {
height: $heightDiff,
visibleHeight: $visibleHeight,
screenHeight: $screenHeight,
bottomInset: $heightDiff,
timestamp: $now,
keyboardVisible: true
}
}));
""".trimIndent()
webView.evaluateJavascript(script, null)
}
} else {
if (isKeyboardVisible) {
isKeyboardVisible = false
val script = """
window.dispatchEvent(new CustomEvent('keyboardDidHide', {
detail: {
height: 0,
visibleHeight: $visibleHeight,
screenHeight: $screenHeight,
bottomInset: 0,
timestamp: $now,
keyboardVisible: false
}
}));
""".trimIndent()
webView.evaluateJavascript(script, null)
}
}
if (imeHeight > 0) {
// 键盘显示
val script = """
document.documentElement.style.setProperty('--keyboard-height', '${heightDp}px');
window.dispatchEvent(new CustomEvent('keyboardDidShow', {
detail: { height: $heightDp }
}));
""".trimIndent()
webView.evaluateJavascript(script, null)
} else {
// 键盘隐藏
val script = """
document.documentElement.style.setProperty('--keyboard-height', '0px');
window.dispatchEvent(new CustomEvent('keyboardDidHide', {
detail: { height: 0 }
}));
""".trimIndent()
webView.evaluateJavascript(script, null)
}
})
insets
}
}
}

View File

@@ -30,15 +30,11 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC</string>
@@ -48,7 +44,5 @@
<string>HuLa needs screen recording permission to enable screenshot functionality</string>
<key>NSAccessibilityUsageDescription</key>
<string>HuLa needs accessibility permission to capture screen content</string>
<key>NSCameraUsageDescription</key>
<string>HuLa使用摄像头</string>
</dict>
</plist>

File diff suppressed because one or more lines are too long

View File

@@ -3950,6 +3950,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4022,6 +4028,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4394,6 +4406,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4466,6 +4484,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -3914,6 +3914,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -3986,6 +3992,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4358,6 +4370,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4430,6 +4448,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -2480,6 +2480,84 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "This permission set configures which\nbarcode scanning features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all barcode related features.\n\n\n#### This default permission set includes:\n\n- `allow-cancel`\n- `allow-check-permissions`\n- `allow-open-app-settings`\n- `allow-request-permissions`\n- `allow-scan`\n- `allow-vibrate`",
"type": "string",
"const": "barcode-scanner:default",
"markdownDescription": "This permission set configures which\nbarcode scanning features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all barcode related features.\n\n\n#### This default permission set includes:\n\n- `allow-cancel`\n- `allow-check-permissions`\n- `allow-open-app-settings`\n- `allow-request-permissions`\n- `allow-scan`\n- `allow-vibrate`"
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the open_app_settings command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-open-app-settings",
"markdownDescription": "Enables the open_app_settings command without any pre-configured scope."
},
{
"description": "Enables the request_permissions command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-request-permissions",
"markdownDescription": "Enables the request_permissions command without any pre-configured scope."
},
{
"description": "Enables the scan command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-scan",
"markdownDescription": "Enables the scan command without any pre-configured scope."
},
{
"description": "Enables the vibrate command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:allow-vibrate",
"markdownDescription": "Enables the vibrate command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the open_app_settings command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-open-app-settings",
"markdownDescription": "Denies the open_app_settings command without any pre-configured scope."
},
{
"description": "Denies the request_permissions command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-request-permissions",
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
},
{
"description": "Denies the scan command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-scan",
"markdownDescription": "Denies the scan command without any pre-configured scope."
},
{
"description": "Denies the vibrate command without any pre-configured scope.",
"type": "string",
"const": "barcode-scanner:deny-vibrate",
"markdownDescription": "Denies the vibrate command without any pre-configured scope."
},
{
"description": "No features are enabled by default, as we believe\nthe clipboard can be inherently dangerous and it is \napplication specific if read and/or write access is needed.\n\nClipboard interaction needs to be explicitly enabled.\n",
"type": "string",
@@ -3872,6 +3950,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -3944,6 +4028,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4316,6 +4406,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4388,6 +4484,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -3914,6 +3914,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -3986,6 +3992,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4358,6 +4370,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4430,6 +4448,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -3950,6 +3950,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4022,6 +4028,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4394,6 +4406,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4466,6 +4484,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -3914,6 +3914,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -3986,6 +3992,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -4358,6 +4370,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -4430,6 +4448,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",

View File

@@ -11,9 +11,7 @@
},
"bundle": {
"active": true,
"resources": [
"configuration"
],
"resources": ["configuration"],
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -41,7 +39,7 @@
"windows": [
{
"title": "登录",
"label": "login",
"label": "mobile-home",
"url": "/mobile/login",
"width": 800,
"height": 600,

View File

@@ -11,9 +11,7 @@
},
"bundle": {
"active": true,
"resources": [
"configuration"
],
"resources": ["configuration"],
"targets": "all",
"iOS": {
"minimumSystemVersion": "13.0"
@@ -23,7 +21,7 @@
"windows": [
{
"title": "登录",
"label": "login",
"label": "mobile-home",
"url": "/mobile/login",
"resizable": false,
"fullscreen": true

View File

@@ -1,9 +1,9 @@
import { onMounted, onUnmounted } from 'vue'
import type { IKeyboardDidShowDetail } from '@/mobile/mobile-client/interface/adapter'
import type { IKeyboardDidShowDetail } from '#/mobile-client/interface/adapter'
import { useMobileStore } from '@/stores/mobile'
import { isMobile } from '@/utils/PlatformConstants'
export function useMobile() {
export const useMobile = () => {
const mobileStore = useMobileStore()
let removeShowFunction: () => void = () => {}
@@ -11,7 +11,7 @@ export function useMobile() {
onMounted(async () => {
if (isMobile()) {
const module = await import('@/mobile/mobile-client/MobileClient')
const module = await import('#/mobile-client/MobileClient')
await module.initMobileClient()

View File

@@ -1,10 +1,10 @@
import 'uno.css'
import '@unocss/reset/eric-meyer.css' // unocss提供的浏览器默认样式重置
import { initMobileClient } from '#/mobile-client/MobileClient'
import App from '@/App.vue'
import { AppException } from '@/common/exception.ts'
import vResize from '@/directives/v-resize'
import vSlide from '@/directives/v-slide.ts'
import { initMobileClient } from '@/mobile/mobile-client/MobileClient'
import router from '@/router'
import { pinia } from '@/stores'
import { initializePlatform } from '@/utils/PlatformConstants'

View File

@@ -7,16 +7,21 @@
@touchend="handleTouchEnd">
<!-- 下拉指示器 -->
<div
class="refresh-indicator absolute left-0 right-0 z-10 w-full flex-center transform transition-transform duration-300 bg-white/80 backdrop-blur-sm"
:class="[isRefreshing ? 'text-primary-500' : 'text-gray-400', { 'opacity-0': distance === 0 }]"
class="refresh-indicator absolute left-0 right-0 z-30 w-full flex-center transform bg-transparent backdrop-blur-sm"
:class="[
isRefreshing ? 'text-primary-500' : 'text-gray-400',
{ 'opacity-0': distance === 0 },
{ 'transition-transform duration-300': !isDragging }
]"
:style="{
top: '0',
height: `${indicatorHeight}px`,
transform: `translateY(-${indicatorHeight - distance}px)`
transform: `translate3d(0, -${Math.max(indicatorHeight - distance, 0)}px, 0)`,
'will-change': isDragging ? 'transform' : ''
}">
<template v-if="isRefreshing">
<n-spin size="small" />
<span class="ml-2 text-sm">正在刷新...</span>
<img class="size-18px" src="@/assets/img/loading.svg" alt="" />
<span class="ml-2 text-sm color-#333">正在刷新...</span>
</template>
<template v-else>
<div class="flex-center flex-col">
@@ -38,10 +43,11 @@
<!-- 内容区域 -->
<div
ref="contentRef"
class="transform transition-transform duration-300"
:class="['transform', { 'transition-transform duration-300': !isDragging }]"
:style="{
transform: `translateY(${distance}px)`,
minHeight: '100%'
transform: `translate3d(0, ${distance}px, 0)`,
minHeight: '100%',
'will-change': isDragging ? 'transform' : ''
}">
<slot />
</div>
@@ -70,6 +76,27 @@ const contentRef = ref<HTMLElement>()
const distance = ref(0)
const startY = ref(0)
const isRefreshing = ref(false)
const isDragging = ref(false)
// rAF 降频更新,避免高频触发造成抖动
let rafId: number | null = null
let pendingDistance: number | null = null
// 刷新距离
const flushDistance = () => {
if (pendingDistance === null) return
distance.value = pendingDistance
pendingDistance = null
rafId = null
}
// 节流更新距离
const scheduleDistanceUpdate = (val: number) => {
pendingDistance = val
if (rafId == null) {
rafId = requestAnimationFrame(flushDistance)
}
}
// 处理触摸开始
const handleTouchStart = (e: TouchEvent) => {
@@ -79,6 +106,7 @@ const handleTouchStart = (e: TouchEvent) => {
// 只有在顶部才能下拉
if (scrollTop <= 0) {
startY.value = e.touches[0].clientY
isDragging.value = true
}
}
@@ -95,7 +123,8 @@ const handleTouchMove = (e: TouchEvent) => {
if (diff > 0) {
e.preventDefault()
// 使用阻尼系数让下拉变得越来越困难
distance.value = Math.min(diff * 0.4, props.threshold * 1.5)
const next = Math.round(Math.min(diff * 0.4, props.threshold * 1.5))
scheduleDistanceUpdate(next)
}
}
@@ -108,9 +137,17 @@ const handleTouchEnd = () => {
distance.value = props.indicatorHeight
emit('refresh')
} else {
// 回弹到初始位置
distance.value = 0
}
startY.value = 0
isDragging.value = false
// 取消未完成的 rAF
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
pendingDistance = null
}
}
// 完成刷新

View File

@@ -7,14 +7,18 @@
<div
class="self-center h-auto transition-transform duration-300 ease-in-out origin-top"
:style="{ transform: props.isShow ? 'scale(1) translateY(0)' : 'scale(0.62) translateY(0px)' }">
<n-avatar :size="86" src="#" fallback-src="/logo.png" round />
<n-avatar
:size="86"
:src="AvatarUtils.getAvatarUrl(userStore.userInfo.avatar!)"
fallback-src="/logo.png"
round />
</div>
<!-- 基本信息栏 -->
<div ref="infoBox" class="pl-2 flex gap-8px flex-col transition-transform duration-300 ease-in-out">
<!-- 名字与在线状态 -->
<div class="flex flex-warp gap-4 items-center">
<span class="font-bold text-20px text-#373838">苏小研</span>
<span class="font-bold text-20px text-#373838">{{ userStore.userInfo.name }}</span>
<div class="bg-#E7EFE6 flex flex-wrap ps-2 items-center rounded-full gap-1 w-50px h-24px">
<span class="w-12px h-12px rounded-15px bg-#079669"></span>
<span class="text-bold-style" style="font-size: 12px; color: #373838">在线</span>
@@ -22,7 +26,7 @@
</div>
<!-- 账号 -->
<div class="flex flex-warp gap-2 items-center">
<span class="text-bold-style">账号123456789</span>
<span class="text-bold-style">账号:{{ userStore.userInfo.account }}</span>
<span @click="toMyQRCode" class="pe-15px">
<img class="w-14px h-14px" src="@/assets/mobile/my/qr-code.webp" alt="" />
</span>
@@ -98,6 +102,10 @@
<script setup lang="ts">
// import router from '@/router'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
const userStore = useUserStore()
const router = useRouter()

View File

@@ -6,16 +6,39 @@
<!-- 页面全部内容 -->
<div class="flex flex-col flex-1">
<RouterView />
<RouterView v-slot="{ Component }">
<Transition name="slide" appear mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</div>
<!-- 底部安全区域占位元素 -->
<SafeAreaPlaceholder type="layout" class="" direction="bottom" />
<SafeAreaPlaceholder type="layout" class="bg-#FAFAFA" direction="bottom" />
</div>
</template>
<script setup lang="ts">
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import { useRoute } from 'vue-router'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
const route = useRoute()
</script>
<style scoped></style>
<style scoped>
/* 侧滑切换动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.1s ease;
}
.slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
</style>

View File

@@ -1,10 +1,34 @@
<template>
<!-- 页面全部内容 -->
<div class="flex flex-col flex-1">
<RouterView />
<RouterView v-slot="{ Component }">
<Transition name="slide" appear mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useRoute } from 'vue-router'
<style scoped></style>
const route = useRoute()
</script>
<style scoped>
/* 侧滑切换动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.1s ease;
}
.slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="h-100vh flex flex-col">
<!-- 考虑不需要这个元素因为有些页面是占满顶部的考虑按需引入 -->
<!-- 顶部安全区域占位元素 -->
<SafeAreaPlaceholder type="layout" class="" direction="top" />
<!-- 页面全部内容 -->
<div class="flex flex-col flex-1">
<RouterView v-slot="{ Component }">
<Transition name="slide" appear mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</div>
<!-- 底部安全区域占位元素 -->
<SafeAreaPlaceholder type="layout" class="" direction="bottom" />
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
const route = useRoute()
</script>
<style lang="scss" scoped>
/* 侧滑切换动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.1s ease;
}
.slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
</style>

View File

@@ -8,7 +8,7 @@
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="flex flex-1 overflow-y-auto">
<RouterView v-slot="{ Component }">
<Transition name="slide" appear>
<Transition name="slide" appear mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
@@ -24,12 +24,24 @@
</template>
<script setup lang="ts">
import { emitTo } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { type } from '@tauri-apps/plugin-os'
import { useRoute } from 'vue-router'
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import type { default as TabBarType } from '@/mobile/layout/tabBar/index.vue'
import TabBar from '@/mobile/layout/tabBar/index.vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import type { default as TabBarType } from '#/layout/tabBar/index.vue'
import TabBar from '#/layout/tabBar/index.vue'
import { MittEnum, NotificationTypeEnum, TauriCommand } from '@/enums'
import { useMitt } from '@/hooks/useMitt'
import type { MessageType } from '@/services/types'
import { WsResponseMessageType } from '@/services/wsType'
import { useChatStore } from '@/stores/chat'
import { useGlobalStore } from '@/stores/global'
import { useMobileStore } from '@/stores/mobile'
import { useUserStore } from '@/stores/user'
import { audioManager } from '@/utils/AudioManager'
import { calculateElementPosition } from '@/utils/DomCalculate'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
const route = useRoute()
const mobileStore = useMobileStore()
@@ -53,6 +65,78 @@ const updateTabBarPosition = async (isInit: boolean) => {
})
}
const chatStore = useChatStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const userUid = computed(() => userStore.userInfo.uid)
const playMessageSound = async () => {
try {
const audio = new Audio('/sound/message.mp3')
await audioManager.play(audio, 'message-notification')
} catch (error) {
console.warn('播放消息音效失败:', error)
}
}
/** 测试 */
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
chatStore.pushMsg(data)
data.message.sendTime = new Date(data.message.sendTime).getTime()
await invokeSilently(TauriCommand.SAVE_MSG, {
data
})
if (data.fromUser.uid !== userUid.value) {
// 获取该消息的会话信息
const session = chatStore.sessionList.find((s) => s.roomId === data.message.roomId)
// 只有非免打扰的会话才发送通知和触发图标闪烁
if (session && session.muteNotification !== NotificationTypeEnum.NOT_DISTURB) {
// 检查 home 窗口状态
const home = await WebviewWindow.getByLabel('mobile-home')
let shouldPlaySound = false
if (home) {
try {
const isVisible = await home.isVisible()
const isMinimized = await home.isMinimized()
const isFocused = await home.isFocused()
// 如果窗口不可见、被最小化或未聚焦,则播放音效
shouldPlaySound = !isVisible || isMinimized || !isFocused
} catch (error) {
console.warn('检查窗口状态失败:', error)
// 如果检查失败,默认播放音效
shouldPlaySound = true
}
} else {
// 如果找不到 home 窗口,播放音效
shouldPlaySound = true
}
// 播放消息音效
if (shouldPlaySound) {
await playMessageSound()
}
// 设置图标闪烁
useMitt.emit(MittEnum.MESSAGE_ANIMATION, data)
// session.unreadCount++
// 在windows系统下才发送通知
if (type() === 'windows') {
globalStore.setTipVisible(true)
}
if (WebviewWindow.getCurrent().label === 'mobile-home') {
await emitTo('notify', 'notify_content', data)
}
}
}
await globalStore.updateGlobalUnreadCount()
})
/** 测试-结束 */
onMounted(async () => {
await updateTabBarPosition(true)
})
@@ -75,16 +159,16 @@ watch(
/* 侧滑切换动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.15s ease;
transition: all 0.1s ease;
}
.slide-enter-from {
transform: translateX(30px);
transform: translateX(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(-30px);
transform: translateX(30px);
opacity: 0;
}
</style>

View File

@@ -6,7 +6,11 @@
<!-- 页面全部内容 -->
<div class="flex flex-col flex-1">
<RouterView />
<RouterView v-slot="{ Component }">
<Transition name="slide" appear mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</div>
<!-- 底部安全区域占位元素 -->
@@ -15,7 +19,26 @@
</template>
<script setup lang="ts">
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import { useRoute } from 'vue-router'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
const route = useRoute()
</script>
<style scoped></style>
<style lang="scss" scoped>
/* 侧滑切换动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.1s ease;
}
.slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
</style>

View File

@@ -11,7 +11,7 @@
<div
@click="activeTab = 'login'"
:class="[
'z-999 w-100px text-center transition-all duration-300 ',
'z-999 w-100px text-center transition-all duration-300 ease-out',
activeTab === 'login' ? 'text-(18px #000)' : 'text-(16px #666)'
]">
登录
@@ -19,7 +19,7 @@
<div
@click="activeTab = 'register'"
:class="[
'z-999 w-100px text-center transition-all duration-300 ',
'z-999 w-100px text-center transition-all duration-300 ease-out',
activeTab === 'register' ? 'text-(18px #000)' : 'text-(16px #666)'
]">
注册
@@ -113,7 +113,7 @@
tertiary
style="color: #fff"
class="w-full mt-8px mb-50px gradient-button"
@click="normalLogin">
@click="normalLogin(false)">
<span>{{ loginText }}</span>
</n-button>
@@ -308,29 +308,30 @@
</template>
<script setup lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { emit } from '@tauri-apps/api/event'
import { lightTheme } from 'naive-ui'
import { ErrorType } from '@/common/exception'
import PinInput from '@/components/common/PinInput.vue'
import Validation from '@/components/common/Validation.vue'
import { TauriCommand } from '@/enums'
import { useLogin } from '@/hooks/useLogin'
import type { RegisterUserReq, UserInfoType } from '@/services/types'
import { useLoginHistoriesStore } from '@/stores/loginHistory.ts'
import { useMobileStore } from '@/stores/mobile'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { getCaptcha, getUserDetail, login, register, sendCaptcha } from '@/utils/ImRequestUtils'
import { getAllUserState, getCaptcha, getUserDetail, register, sendCaptcha } from '@/utils/ImRequestUtils'
import { isAndroid } from '@/utils/PlatformConstants'
import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler'
import router from '../router'
import rustWebSocketClient from '../services/webSocketRust'
import { useUserStatusStore } from '../stores/userStatus'
// 本地注册信息类型扩展API类型以包含确认密码
interface LocalRegisterInfo extends RegisterUserReq {}
const loginHistoriesStore = useLoginHistoriesStore()
const userStore = useUserStore()
const { setLoginState } = useLogin()
const { loginHistories } = loginHistoriesStore
const mobileStore = useMobileStore()
const safeArea = computed(() => mobileStore.safeArea)
@@ -359,7 +360,7 @@ const registerInfo = ref<LocalRegisterInfo>({
code: '',
uuid: '',
avatar: '',
key: '',
key: 'REGISTER_EMAIL',
systemType: 2
})
@@ -584,58 +585,128 @@ const handleRegisterComplete = async () => {
}
/**登录后创建主页窗口*/
const normalLogin = async () => {
const normalLogin = async (auto = false) => {
loading.value = true
const { account, password } = info.value
login({ account, password, deviceType: 'MOBILE', systemType: 2, grantType: 'PASSWORD' })
.then(async (res) => {
loginText.value = '登录中...'
loginDisabled.value = true
// 根据auto参数决定从哪里获取登录信息
const loginInfo = auto ? (userStore.userInfo as UserInfoType) : info.value
const { account } = loginInfo
// 自动登录
if (auto) {
// 添加2秒延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
// TODO 自动登录
// try {
// // 登录处理
//
// loginProcess(null, null, null)
// } catch (error) {
// console.error('自动登录失败', error)
// // 如果是网络异常不删除token
// if (!isOnline.value) {
// loginDisabled.value = true
// loginText.value = '网络异常'
// loading.value = false
// } else {
// // 其他错误才清除token并重置状态
// localStorage.removeItem('TOKEN')
// isAutoLogin.value = false
// loginDisabled.value = true
// loginText.value = '登录'
// loading.value = false
// }
// }
return
}
invoke('login_command', {
data: {
account: account,
password: info.value.password,
deviceType: 'MOBILE',
systemType: '2', // 2是im 1是后台
grantType: 'PASSWORD'
}
})
.then(async (res: any) => {
loginDisabled.value = true
loginText.value = '登录成功, 正在跳转'
userStore.isSign = true
// 存储双token
localStorage.setItem('TOKEN', res.token)
localStorage.setItem('REFRESH_TOKEN', res.refreshToken)
// 需要删除二维码,因为用户可能先跳转到二维码界面再回到登录界面,会导致二维码一直保持在内存中
if (localStorage.getItem('wsLogin')) {
localStorage.removeItem('wsLogin')
}
// 获取用户详情
const userDetail: any = await getUserDetail()
// TODO 先不获取 emoji 列表,当我点击 emoji 按钮的时候再获取
// await emojiStore.getEmojiList()
// TODO 这里的id暂时赋值给uid因为后端没有统一返回uid待后端调整
const account = {
...userDetail,
token: res.token,
client: res.client
}
await invokeWithErrorHandler(
TauriCommand.SAVE_USER_INFO,
{
userInfo: account
},
{
customErrorMessage: '保存用户信息失败',
errorType: ErrorType.Client
}
)
console.log('登录成功')
await emit('set_user_info', {
token: res.token,
refreshToken: res.refreshToken,
uid: account.uid
})
// 开启 ws 连接
await rustWebSocketClient.initConnect()
// 登录处理
await loginProcess(res.token, res.refreshToken, res.client)
})
.catch((e: any) => {
console.error('登录异常:', e)
loading.value = false
loginDisabled.value = false
loginText.value = '登录'
// 如果是自动登录失败,重置按钮状态允许手动登录
if (auto) {
loginDisabled.value = false
loginText.value = '登录'
}
})
}
loading.value = false
userStore.userInfo = account
loginHistoriesStore.addLoginHistory(account)
router.push('/mobile/message')
await setLoginState()
})
.catch((e) => {
loading.value = false
console.error(e)
})
const userStatusStore = useUserStatusStore()
const { stateId } = storeToRefs(userStatusStore)
const loginProcess = async (token: string, refreshToken: string, client: string) => {
loading.value = false
// 获取用户状态列表
if (userStatusStore.stateList.length === 0) {
try {
userStatusStore.stateList = await getAllUserState()
} catch (error) {
console.error('获取用户状态列表失败', error)
}
}
// 获取用户详情
// const userDetail = await apis.getUserDetail()
const userDetail: any = await getUserDetail()
// 设置用户状态id
stateId.value = userDetail.userStateId
// const token = localStorage.getItem('TOKEN')
// const refreshToken = localStorage.getItem('REFRESH_TOKEN')
// TODO 先不获取 emoji 列表,当我点击 emoji 按钮的时候再获取
// await emojiStore.getEmojiList()
const account = {
...userDetail,
token,
refreshToken,
client
}
userStore.userInfo = account
loginHistoriesStore.addLoginHistory(account)
// 在 sqlite 中存储用户信息
await invokeWithErrorHandler(
TauriCommand.SAVE_USER_INFO,
{
userInfo: userDetail
},
{
customErrorMessage: '保存用户信息失败',
errorType: ErrorType.Client
}
)
// 在 rust 部分设置 token
await emit('set_user_info', {
token,
refreshToken,
uid: userDetail.uid
})
loginText.value = '登录成功正在跳转...'
router.push('/mobile/message')
}
/**

View File

@@ -8,7 +8,6 @@ import type {
} from './interface/adapter'
export class IosAdapter implements IMobileClientAdapter {
// TODO 着个没测试过,需要测试才行
keyboardListener(showCallback: TKeyboardShowCallback, hideCallback: TKeyboardHideCallback): KeyboardListenerResult {
const showHandler = (e: Event) => {
const detail = (e as CustomEvent<IKeyboardDidShowDetail>).detail

View File

@@ -1,7 +1,7 @@
<template>
<AutoFixHeightPage>
<template #header>
<HeaderBar ref="header" :room-name="roomName" :msg-count="1002" />
<HeaderBar ref="header" :room-name="currentChatRoom.name" :msg-count="1002" />
</template>
<template #container>
<!-- 网络状态提示 -->
@@ -442,13 +442,15 @@
</template>
<script setup lang="ts">
import AutoFixHeightPage from '@/mobile/components/chat-room/AutoFixHeightPage.vue'
import FooterBar from '@/mobile/components/chat-room/FooterBar.vue'
import HeaderBar from '@/mobile/components/chat-room/HeaderBar.vue'
import AutoFixHeightPage from '#/components/chat-room/AutoFixHeightPage.vue'
import FooterBar from '#/components/chat-room/FooterBar.vue'
import HeaderBar from '#/components/chat-room/HeaderBar.vue'
import { useMobileStore } from '@/stores/mobile'
import { markMsg } from '@/utils/ImRequestUtils'
const route = useRoute()
const roomName = ref(route.params.roomName as string)
const mobileStore = useMobileStore()
const currentChatRoom = mobileStore.currentChatRoom
const header = ref()

View File

@@ -41,9 +41,9 @@
</template>
<script setup lang="ts">
import CommunityContent from '@/mobile/components/community/CommunityContent.vue'
import CommunityTab from '@/mobile/components/community/CommunityTab.vue'
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import CommunityContent from '#/components/community/CommunityContent.vue'
import CommunityTab from '#/components/community/CommunityTab.vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import router from '@/router'
const toScanQRCode = () => [router.push('/mobile/mobileMy/scanQRcode')]

View File

@@ -0,0 +1,277 @@
<template>
<div class="flex w-full flex-col gap-3">
<!-- 键盘蒙板 -->
<div
v-if="showKeyboardMask"
class="keyboard-mask flex-1"
@touchstart.stop.prevent="closeKeyboardMask"
@click.stop.prevent="closeKeyboardMask"></div>
<div class="px-16px mt-5px flex gap-3">
<div class="flex-1 py-5px shrink-0">
<n-input
id="search"
class="rounded-10px w-full bg-gray-100 relative text-14px"
:maxlength="20"
clearable
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="输入用户名字/账号搜索~"
@focus="lockScroll"
@blur="unlockScroll">
<template #prefix>
<svg class="w-12px h-12px"><use href="#search"></use></svg>
</template>
</n-input>
</div>
<div class="flex justify-end items-center">
<n-button class="py-5px">搜索</n-button>
</div>
</div>
<div
ref="scrollArea"
id="scrollArea"
:style="{ height: scrollHeight + 'px' }"
class="px-16px overflow-y-auto scroll-auto h-100px">
<div class="flex flex-1 flex-col gap-2">
<n-tabs type="segment" animated class="mt-4px">
<!-- 用户 -->
<n-tab-pane name="1" tab="用户">
<div class="flex justify-between items-center mb-2">
<span class="font-bold"></span>
<span class="text-(10px #707070)">{{ onlineCount }}/{{ contactStore.contactsList.length }}</span>
</div>
<n-scrollbar style="max-height: calc(100vh - 220px)">
<div @contextmenu.stop="$event.preventDefault()">
<n-flex
:size="10"
@click="handleClick(item.uid, RoomTypeEnum.SINGLE)"
:class="{ active: activeItem === item.uid }"
class="item-box w-full h-75px mb-5px"
v-for="item in sortedContacts"
:key="item.uid">
<n-flex
align="center"
justify="space-between"
:size="10"
class="h-75px pl-6px pr-8px flex-1 truncate">
<!-- 左边用户信息 -->
<n-flex align="center" :size="10" class="flex-1 truncate">
<n-avatar
round
style="border: 1px solid var(--avatar-border-color)"
:size="44"
class="grayscale"
:class="{ 'grayscale-0': item.activeStatus === OnlineEnum.ONLINE }"
:src="AvatarUtils.getAvatarUrl(useUserInfo(item.uid).value.avatar!)"
fallback-src="/logo.png" />
<n-flex vertical justify="space-between" class="h-fit flex-1 truncate">
<span class="text-14px leading-tight flex-1 truncate">
{{ useUserInfo(item.uid).value.name }}
</span>
<div class="text leading-tight text-12px flex-y-center gap-4px flex-1 truncate">
[
<template v-if="getUserState(item.uid)">
<img class="size-12px rounded-50%" :src="getUserState(item.uid)?.url" alt="" />
{{ getUserState(item.uid)?.title }}
</template>
<template v-else>
<n-badge :color="item.activeStatus === OnlineEnum.ONLINE ? '#1ab292' : '#909090'" dot />
{{ item.activeStatus === OnlineEnum.ONLINE ? '在线' : '离线' }}
</template>
]
</div>
</n-flex>
</n-flex>
<!-- 右边操作按钮 -->
<n-button size="small" @click.stop="addFriend(item.uid)">添加</n-button>
</n-flex>
</n-flex>
</div>
</n-scrollbar>
</n-tab-pane>
<!-- 群聊 -->
<n-tab-pane name="2" tab="群聊">
<div class="flex justify-between items-center mb-2">
<span class="font-bold"></span>
<span class="text-(10px #707070)">{{ groupChatList.length }}</span>
</div>
<n-scrollbar style="max-height: calc(100vh - 220px)">
<div
@click="handleClick(item.roomId, RoomTypeEnum.GROUP)"
:class="{ active: activeItem === item.roomId }"
class="item-box w-full h-75px mb-5px"
v-for="item in groupChatList"
:key="item.roomId">
<n-flex align="center" justify="space-between" :size="10" class="h-75px pl-6px pr-8px flex-1 truncate">
<!-- 左边群聊信息 -->
<n-flex align="center" :size="10" class="flex-1 truncate">
<n-avatar
round
style="border: 1px solid var(--avatar-border-color)"
bordered
:size="44"
:src="AvatarUtils.getAvatarUrl(item.avatar)"
fallback-src="/logo.png" />
<span class="text-14px leading-tight flex-1 truncate">{{ item.remark || item.roomName }}</span>
</n-flex>
<!-- 右边操作按钮 -->
<n-button size="small" @click.stop="joinGroup(item.roomId)">添加</n-button>
</n-flex>
</div>
</n-scrollbar>
</n-tab-pane>
</n-tabs>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { MittEnum, OnlineEnum, RoomTypeEnum } from '@/enums'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useMitt } from '@/hooks/useMitt.ts'
import { useChatStore } from '@/stores/chat.ts'
import { useContactStore } from '@/stores/contacts.ts'
import { useUserStatusStore } from '@/stores/userStatus'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { useMobileStore } from '~/src/stores/mobile'
import { calculateElementPosition } from '~/src/utils/DomCalculate'
const scrollArea = ref<HTMLDivElement>()
const mobileStore = useMobileStore()
const scrollHeight = ref(700) // 默认高度
onMounted(async () => {
try {
const scrollAreaRect = await calculateElementPosition(scrollArea)
scrollHeight.value = window.innerHeight - scrollAreaRect!.y - mobileStore.safeArea.bottom
} catch (error) {
console.log('计算[scrollArea]高度失败:', error)
}
})
// 锁滚动(和蒙板一样)
const lockScroll = () => {
const scrollEl = document.querySelector('#scrollArea') as HTMLElement
if (scrollEl) {
scrollEl.style.overflow = 'hidden'
}
}
const unlockScroll = () => {
const scrollEl = document.querySelector('#scrollArea') as HTMLElement
if (scrollEl) {
scrollEl.style.overflow = 'auto'
}
}
// 键盘蒙板显示状态
const showKeyboardMask = ref(false)
const closeKeyboardMask = () => {
showKeyboardMask.value = false
document.body.style.overflow = ''
document.body.style.position = ''
// 让 input 失焦
const activeEl = document.activeElement as HTMLElement
if (activeEl && typeof activeEl.blur === 'function') {
activeEl.blur()
}
}
const addFriend = (_item: any) => {}
const joinGroup = (_item: any) => {}
/** 建议把此状态存入localStorage中 */
const activeItem = ref('')
const detailsShow = ref(false)
const shrinkStatus = ref(false)
const contactStore = useContactStore()
const userStatusStore = useUserStatusStore()
const { stateList } = storeToRefs(userStatusStore)
/** 群聊列表 */
const groupChatList = computed(() => {
console.log(contactStore.groupChatList)
return [...contactStore.groupChatList].sort((a, b) => {
// 将roomId为'1'的群聊排在最前面
if (a.roomId === '1' && b.roomId !== '1') return -1
if (a.roomId !== '1' && b.roomId === '1') return 1
return 0
})
})
/** 统计在线用户人数 */
const onlineCount = computed(() => {
return contactStore.contactsList.filter((item) => item.activeStatus === OnlineEnum.ONLINE).length
})
/** 排序好友列表 */
const sortedContacts = computed(() => {
return [...contactStore.contactsList].sort((a, b) => {
// 在线用户排在前面
if (a.activeStatus === OnlineEnum.ONLINE && b.activeStatus !== OnlineEnum.ONLINE) return -1
if (a.activeStatus !== OnlineEnum.ONLINE && b.activeStatus === OnlineEnum.ONLINE) return 1
return 0
})
})
/** 监听独立窗口关闭事件 */
watchEffect(() => {
useMitt.on(MittEnum.SHRINK_WINDOW, async (event) => {
shrinkStatus.value = event as boolean
})
})
const handleClick = (index: string, type: number) => {
detailsShow.value = true
activeItem.value = index
const data = {
context: {
type: type,
uid: index
},
detailsShow: detailsShow.value
}
useMitt.emit(MittEnum.DETAILS_SHOW, data)
}
/** 获取用户状态 */
const getUserState = (uid: string) => {
const userInfo = useUserInfo(uid).value
const userStateId = userInfo.userStateId
if (userStateId && userStateId !== '1') {
return stateList.value.find((state: { id: string }) => state.id === userStateId)
}
return null
}
onUnmounted(() => {
detailsShow.value = false
useMitt.emit(MittEnum.DETAILS_SHOW, detailsShow.value)
})
const chatStore = useChatStore()
const getSessionList = async () => {
await chatStore.getSessionList(true)
}
onMounted(() => {
getSessionList()
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="flex w-full flex-col h-full">
<!-- 顶部搜索框 -->
<div class="px-16px mt-10px flex gap-3">
<div class="flex-1 py-5px shrink-0">
<n-input
v-model:value="keyword"
class="rounded-10px w-full bg-gray-100 relative text-14px"
placeholder="搜索联系人~"
clearable
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off">
<template #prefix>
<svg class="w-12px h-12px"><use href="#search"></use></svg>
</template>
</n-input>
</div>
<div class="flex justify-end items-center">
<n-button class="py-5px" @click="doSearch">搜索</n-button>
</div>
</div>
<!-- 联系人列表 -->
<!-- 联系人列表 -->
<div ref="scrollArea" class="flex-1 overflow-y-auto px-16px mt-10px" :style="{ height: scrollHeight + 'px' }">
<n-scrollbar style="max-height: calc(100vh - 150px)">
<n-checkbox-group v-model:value="selectedList" class="flex flex-col gap-2">
<div
v-for="item in filteredContacts"
:key="item.uid"
class="rounded-10px border border-gray-200 overflow-hidden">
<n-checkbox
:value="item.uid"
size="large"
class="w-full flex items-center px-5px"
:class="[
'cursor-pointer select-none transition-colors duration-150',
selectedList.includes(item.uid) ? 'bg-blue-50 border-blue-300' : 'hover:bg-gray-50'
]">
<template #default>
<!-- 强制一行展示 -->
<div class="flex items-center gap-10px px-8px py-10px">
<!-- 头像 -->
<n-avatar
round
:size="44"
:src="AvatarUtils.getAvatarUrl(useUserInfo(item.uid).value.avatar!)"
fallback-src="/logo.png"
style="border: 1px solid var(--avatar-border-color)" />
<!-- 文字信息 -->
<div class="flex flex-col leading-tight truncate">
<span class="text-14px font-medium truncate">
{{ useUserInfo(item.uid).value.name }}
</span>
<div class="text-12px text-gray-500 flex items-center gap-4px truncate">
<template v-if="getUserState(item.uid)">
<img class="size-12px rounded-50%" :src="getUserState(item.uid)?.url" alt="" />
{{ getUserState(item.uid)?.title }}
</template>
<template v-else>
<n-badge :color="item.activeStatus === OnlineEnum.ONLINE ? '#1ab292' : '#909090'" dot />
{{ item.activeStatus === OnlineEnum.ONLINE ? '在线' : '离线' }}
</template>
</div>
</div>
</div>
</template>
</n-checkbox>
</div>
</n-checkbox-group>
</n-scrollbar>
</div>
<!-- 底部操作栏 -->
<div class="px-16px py-10px bg-white border-t border-gray-200 flex justify-between items-center">
<span class="text-14px">已选择 {{ selectedList.length }} </span>
<n-button type="primary" :disabled="selectedList.length === 0" @click="createGroup">发起群聊</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { OnlineEnum } from '@/enums'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useContactStore } from '@/stores/contacts'
import { useUserStatusStore } from '@/stores/userStatus'
import { AvatarUtils } from '@/utils/AvatarUtils'
const userStatusStore = useUserStatusStore()
const { stateList } = storeToRefs(userStatusStore)
/** 获取用户状态 */
const getUserState = (uid: string) => {
const userInfo = useUserInfo(uid).value
const userStateId = userInfo.userStateId
if (userStateId && userStateId !== '1') {
return stateList.value.find((state: { id: string }) => state.id === userStateId)
}
return null
}
// store
const contactStore = useContactStore()
// 搜索关键字
const keyword = ref('')
// 选中的联系人 uid 数组
const selectedList = ref<string[]>([])
// 滚动高度计算
const scrollHeight = ref(600)
onMounted(() => {
scrollHeight.value = window.innerHeight - 180
})
// 搜索逻辑
const doSearch = () => {
// 这里只是触发响应式,实际过滤逻辑写在 computed 里
}
const filteredContacts = computed(() => {
if (!keyword.value) return contactStore.contactsList
return contactStore.contactsList.filter((c) => {
const name = useUserInfo(c.uid).value.name
if (name) {
name.includes(keyword.value)
} else {
false
}
})
})
// 点击发起群聊
const createGroup = () => {
console.log('发起群聊,选择的用户:', selectedList.value)
// TODO: 调用接口 / store 创建群聊
}
</script>
<style lang="scss" scoped></style>

View File

@@ -174,14 +174,14 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import NavBar from '#/layout/navBar/index.vue'
import addFriendIcon from '@/assets/mobile/chat-home/add-friend.webp'
import groupChatIcon from '@/assets/mobile/chat-home/group-chat.webp'
import { MittEnum, OnlineEnum, RoomTypeEnum } from '@/enums'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useMitt } from '@/hooks/useMitt.ts'
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import NavBar from '@/mobile/layout/navBar/index.vue'
import router from '@/router'
import { useChatStore } from '@/stores/chat.ts'
import { useContactStore } from '@/stores/contacts.ts'
import { useUserStatusStore } from '@/stores/userStatus'
@@ -208,12 +208,12 @@ const uiViewsData = ref({
addOptions: [
{
label: '发起群聊',
key: 'profile',
key: '/mobile/mobileFriends/startGroupChat',
icon: renderImgIcon(groupChatIcon)
},
{
label: '加好友/群',
key: 'editProfile',
key: '/mobile/mobileFriends/addFriends',
icon: renderImgIcon(addFriendIcon)
}
]
@@ -359,7 +359,9 @@ const addIconHandler = {
/**
* 选项选择时关闭蒙板
*/
select: () => {
select: (item: string) => {
console.log('选择的项:', item)
router.push(item)
maskHandler.close()
},

View File

@@ -12,9 +12,16 @@
@click="maskHandler.close"
class="fixed inset-0 bg-black/20 backdrop-blur-sm z-[999] transition-all duration-3000 ease-in-out opacity-100"></div>
<!-- 键盘蒙板 -->
<div
v-if="showKeyboardMask"
class="keyboard-mask flex-1"
@touchstart.stop.prevent="closeKeyboardMask"
@click.stop.prevent="closeKeyboardMask"></div>
<NavBar>
<template #left>
<n-flex align="center" :size="6" class="w-full">
<n-flex @click="toSimpleBio" align="center" :size="6" class="w-full">
<n-avatar
:size="38"
:src="AvatarUtils.getAvatarUrl(userStore.userInfo.avatar!)"
@@ -33,7 +40,7 @@
class="text-(16px [--text-color])">
{{ userStore.userInfo.name }}
</p>
<p class="text-(10px [--text-color])"> 柳州鱼峰</p>
<p class="text-(10px [--text-color])">{{ useUserInfo(userStore.userInfo.uid).value.locPlace || '未知' }}</p>
</n-flex>
</n-flex>
</template>
@@ -52,54 +59,54 @@
</template>
</NavBar>
<div class="px-16px mt-5px">
<div class="py-5px shrink-0">
<n-input
id="search"
class="rounded-6px w-full bg-white relative text-12px"
:maxlength="20"
clearable
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="搜索"
@focus="lockScroll"
@blur="unlockScroll">
<template #prefix>
<svg class="w-12px h-12px"><use href="#search"></use></svg>
</template>
</n-input>
</div>
<div class="border-b-1 border-solid color-gray-200 px-18px mt-5px"></div>
</div>
<PullToRefresh class="flex-1 overflow-auto" @refresh="handleRefresh" ref="pullRefreshRef">
<div class="flex flex-col h-full px-18px">
<div class="py-8px shrink-0">
<n-input
id="search"
class="rounded-6px w-full bg-white relative text-12px"
:maxlength="20"
clearable
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
:placeholder="'搜索'">
<template #prefix>
<svg class="w-12px h-12px"><use href="#search"></use></svg>
</template>
</n-input>
</div>
<div class="border-b-1 border-solid color-gray-200 px-18px my-8px"></div>
<div class="flex-1">
<div
v-for="item in messageItems"
:key="item.id"
v-for="(item, idx) in messageItems"
:key="`${item.id}-${idx}`"
@click="intoRoom(item)"
class="grid grid-cols-[2.2rem_1fr_4rem] items-start px-2 py-3 gap-1 active:bg-#DEEDE7 active:rounded-10px transition-colors cursor-pointer">
class="grid grid-cols-[2.2rem_1fr_4rem] items-start px-2 py-3 gap-1">
<!-- 头像单独居中 -->
<div class="self-center h-38px">
<n-badge :value="item.unreadCount">
<n-avatar :size="40" :src="AvatarUtils.getAvatarUrl(item.avatar)" fallback-src="/logo.png" round />
<div class="flex-shrink-0">
<n-badge :offset="[-6, 6]" :value="item.unreadCount" :max="99">
<n-avatar :size="52" :src="AvatarUtils.getAvatarUrl(item.avatar)" fallback-src="/logo.png" round />
</n-badge>
</div>
<!-- {{ item }} -->
<!-- 中间两行内容 -->
<div class="truncate pl-4 flex gap-10px flex-col">
<div class="text-14px leading-tight font-bold flex-1 truncate text-#333 truncate">{{ item.name }}</div>
<div class="truncate pl-7 flex pt-5px gap-10px flex-col">
<div class="text-16px leading-tight font-bold flex-1 truncate text-#333 truncate">{{ item.name }}</div>
<div class="text-12px text-#333 truncate">
{{ item.text }}
</div>
</div>
<!-- 时间靠顶 -->
<div class="text-12px text-right flex gap-1 items-center justify-right">
<div class="text-12px pt-9px text-right flex gap-1 items-center justify-right">
<span v-if="item.hotFlag === IsAllUserEnum.Yes">
<svg class="size-20px select-none outline-none cursor-pointer color-#13987f">
<svg class="size-22px select-none outline-none cursor-pointer color-#13987f">
<use href="#auth"></use>
</svg>
</span>
@@ -108,25 +115,6 @@
</span>
</div>
</div>
<!-- <div v-for="item in messageItems" :key="item.id" class="message-item relative">
<div>
<n-badge :value="item.unreadCount">
<n-avatar :size="40" :src="item.avatar" fallback-src="/logo.png" round />
</n-badge>
<div class="flex flex-col ml-14px justify-between h-[35px]">
<span class="text-14px text-#333">{{ item.name }}</span>
<span class="text-12px text-#999">{{ item.text }}</span>
</div>
</div>
<div class="flex justify-right text text-12px w-fit truncate text-right">
{{ formatTimestamp(item?.activeTime) }}
</div>
未读数 悬浮到头像上
<div v-if="item.unreadCount > 0" class="flex flex-col justify-between h-[35px] absolute left-30px top-5px">
<span class="text-12px text-[#fff] bg-[red] rounded-full px-8px py-3px">{{ item.unreadCount }}</span>
</div>
</div> -->
</div>
</div>
</PullToRefresh>
@@ -134,18 +122,24 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PullToRefresh from '#/components/PullToRefresh.vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import NavBar from '#/layout/navBar/index.vue'
import type { IKeyboardDidShowDetail } from '#/mobile-client/interface/adapter'
import { mobileClient } from '#/mobile-client/MobileClient'
import addFriendIcon from '@/assets/mobile/chat-home/add-friend.webp'
import groupChatIcon from '@/assets/mobile/chat-home/group-chat.webp'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useMessage } from '@/hooks/useMessage.ts'
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import NavBar from '@/mobile/layout/navBar/index.vue'
import { IsAllUserEnum } from '@/services/types.ts'
import rustWebSocketClient from '@/services/webSocketRust'
import { useChatStore } from '@/stores/chat.ts'
import { useUserStore } from '@/stores/user.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { MittEnum } from '~/src/enums'
import { useMitt } from '~/src/hooks/useMitt'
import { useMobileStore } from '~/src/stores/mobile'
const chatStore = useChatStore()
@@ -157,8 +151,43 @@ const getSessionList = async () => {
await chatStore.getSessionList(true)
}
onMounted(() => {
const userStore = useUserStore()
/**
* export interface IKeyboardDidShowDetail {
bottomInset: number
height: number
keyboardVisible: boolean
screenHeight: number
timestamp: number
visibleHeight: number
}
*/
onMounted(async () => {
await rustWebSocketClient.setupBusinessMessageListeners()
console.log('个人数据:', userStore.userInfo)
getSessionList()
const { removeHideFunction, removeShowFunction } = await mobileClient.keyboardListener(
// 键盘打开
(detail: IKeyboardDidShowDetail) => {
console.log('键盘打开', detail)
openKeyboardMask()
},
// 键盘关闭
() => {
console.log('键盘关闭')
closeKeyboardMask()
}
)
// 如果需要在组件卸载时移除监听
onBeforeUnmount(() => {
removeHideFunction()
removeShowFunction()
})
})
const handleRefresh = async () => {
@@ -167,8 +196,6 @@ const handleRefresh = async () => {
pullRefreshRef.value?.finishRefresh()
}
const userStore = useUserStore()
/**
* 渲染图片图标的函数工厂
* @param {string} src - 图标图片路径
@@ -178,7 +205,7 @@ const renderImgIcon = (src: string) => {
return () =>
h('img', {
src,
style: 'display:block; width: 24px; height: 24px; vertical-align: middle'
style: 'display:block; width: 26px; height: 26px; vertical-align: middle;'
})
}
@@ -190,12 +217,12 @@ const uiViewsData = ref({
addOptions: [
{
label: '发起群聊',
key: 'profile',
key: '/mobile/mobileFriends/startGroupChat',
icon: renderImgIcon(groupChatIcon)
},
{
label: '加好友/群',
key: 'editProfile',
key: '/mobile/mobileFriends/addFriends',
icon: renderImgIcon(addFriendIcon)
}
]
@@ -251,7 +278,9 @@ const addIconHandler = {
/**
* 选项选择时关闭蒙板
*/
select: () => {
select: (item: string) => {
console.log('选择的项:', item)
router.push(item)
maskHandler.close()
},
@@ -272,12 +301,61 @@ const addIconHandler = {
const router = useRouter()
const mobileStore = useMobileStore()
useMitt.on(MittEnum.MSG_BOX_SHOW, (event: any) => {
mobileStore.updateCurrentChatRoom(event.item)
})
const { handleMsgClick } = useMessage()
const intoRoom = (item: any) => {
handleMsgClick(item)
router.push(`/mobile/chatRoom/chatMain/${encodeURIComponent(item.name)}`)
console.log('进入页面', item)
setTimeout(() => {
router.push(`/mobile/chatRoom/chatMain`)
console.log('进入页面', item)
}, 0)
}
const toSimpleBio = () => {
// 切成你想要的离场动画
router.push('/mobile/mobileMy/simpleBio')
}
// 锁滚动(和蒙板一样)
const lockScroll = () => {
console.log('锁定触发')
const scrollEl = document.querySelector('.flex-1.overflow-auto') as HTMLElement
if (scrollEl) {
scrollEl.style.overflow = 'hidden'
}
}
const unlockScroll = () => {
console.log('锁定解除')
const scrollEl = document.querySelector('.flex-1.overflow-auto') as HTMLElement
if (scrollEl) {
scrollEl.style.overflow = 'auto'
}
}
// 键盘蒙板显示状态
const showKeyboardMask = ref(false)
const openKeyboardMask = () => {
showKeyboardMask.value = true
document.body.style.overflow = 'hidden'
document.body.style.position = 'fixed'
}
const closeKeyboardMask = () => {
showKeyboardMask.value = false
document.body.style.overflow = ''
document.body.style.position = ''
// 让 input 失焦
const activeEl = document.activeElement as HTMLElement
if (activeEl && typeof activeEl.blur === 'function') {
activeEl.blur()
}
}
</script>
@@ -285,4 +363,18 @@ const intoRoom = (item: any) => {
// .message-item {
// @apply flex justify-around items-center p-3 hover:bg-#DEEDE7 hover:rounded-10px transition-colors cursor-pointer;
// }
.keyboard-mask {
position: fixed;
inset: 0;
background: transparent; // 透明背景
z-index: 1400; // 低于 Naive 弹层,高于页面内容
pointer-events: auto; // 确保能接收事件
touch-action: none; // 禁止滚动
}
::deep(#search) {
position: relative;
z-index: 1500; // 高于键盘蒙层,低于 Naive 弹层
}
</style>

View File

@@ -43,8 +43,15 @@
</template>
<script setup lang="ts">
import { emit } from '@tauri-apps/api/event'
import { info } from '@tauri-apps/plugin-log'
import { NInput, NSelect, NSwitch } from 'naive-ui'
import { reactive } from 'vue'
import { EventEnum, TauriCommand } from '~/src/enums'
import router from '~/src/router'
import { useGlobalStore } from '~/src/stores/global'
import { invokeSilently } from '~/src/utils/TauriInvokeHandler'
// 定义设置项
const settings = reactive([
@@ -82,9 +89,37 @@ const settings = reactive([
]
}
])
const globalStore = useGlobalStore()
const { isTrayMenuShow } = storeToRefs(globalStore)
const dialog = useDialog()
// 退出登录逻辑
function handleLogout() {}
async function handleLogout() {
dialog.error({
title: '提示',
content: '确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
info('登出账号')
isTrayMenuShow.value = false
try {
// ws 退出连接
await invokeSilently('ws_disconnect')
await invokeSilently(TauriCommand.REMOVE_TOKENS)
await invokeSilently(TauriCommand.UPDATE_USER_LAST_OPT_TIME)
// 发送登出事件
await emit(EventEnum.LOGOUT)
router.push('/mobile/login')
} catch (error) {
console.error('创建登录窗口失败:', error)
}
},
onNegativeClick: () => {
console.log('用户点击了取消')
}
})
}
// 你可以根据需要导出或操作 settings 数据
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div class="flex flex-1">
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% absolute top-0 z-0" alt="hula" />
<AutoFixHeightPage :show-footer="false">
<template #container="{ height }">
<div :style="{ height: height + 'px' }" class="z-2 flex flex-col absolute overflow-auto min-h-70vh w-full">
<div class="flex flex-col flex-1 p-20px gap-20px">
<div class="flex items-center">
<div class="py-15px flex gap-10px w-full items-center justify-end">
<div class="bg-#E7EFE6 flex flex-wrap ps-2 items-center rounded-full gap-1 w-50px h-24px">
<span class="w-12px h-12px rounded-15px bg-#079669"></span>
<span class="text-bold-style" style="font-size: 12px; color: #373838">在线</span>
</div>
<svg @click="toSettings" class="iconpark-icon h-32px w-32px block"><use href="#wode-shezhi"></use></svg>
<svg @click="toScanQRCode" class="iconpark-icon h-32px w-32px block"><use href="#saoma"></use></svg>
<svg @click="handleBack" class="w-32px h-32px iconpark-icon"><use href="#right"></use></svg>
</div>
</div>
<div class="flex shadow bg-white w-full rounded-lg items-center">
<div class="p-10px flex w-full flex-wrap gap-10px">
<div
class="rounded-full shadow self-center h-auto transition-transform duration-300 ease-in-out origin-top"
style="transform: scale(1) translateY(0)">
<n-avatar
:size="74"
:src="AvatarUtils.getAvatarUrl(userStore.userInfo.avatar!)"
fallback-src="/logo.png"
round />
</div>
<div @click="toMyInfo" class="flex flex-col flex-1 py-10px">
<div class="font-bold text-18px text-#373838">{{ userStore.userInfo.name }}</div>
<div class="mt-2 text-bold-style line-height-22px line-clamp-2">
一段自我描述添加性别/地区/工作或学校 不定期更新的日常不定期更新的日常不定期更新的日常
</div>
</div>
<div @click="toMyInfo" class="flex items-center justify-end">
<svg @click="handleBack" class="w-24px text-gray h-24px iconpark-icon"><use href="#right"></use></svg>
</div>
</div>
</div>
<div class="flex flex-col w-full bg-white rounded-lg flex-1">
<div
v-for="item in options"
:key="item.label"
class="flex flex-wrap items-center gap-15px p-[15px_10px_5px_10px]">
<div>
<svg class="iconpark-icon w-30px h-30px"><use :href="'#' + item.icon"></use></svg>
</div>
<div class="flex-1 text-14px">{{ item.label }}</div>
<div>
<svg @click="handleBack" class="w-20px text-gray h-20px iconpark-icon"><use href="#right"></use></svg>
</div>
</div>
</div>
</div>
</div>
</template>
</AutoFixHeightPage>
</div>
</template>
<script setup lang="ts">
import router from '@/router'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
const userStore = useUserStore()
const options = ref([
{
icon: 'xiangce',
label: '相册'
},
{
icon: 'shoucang',
label: '收藏'
},
{
icon: 'wenjian',
label: '文件'
},
{
icon: 'gexingzhuangban',
label: '个性装扮'
}
])
const toSettings = () => {
router.push('/mobile/mobileMy/settings')
}
const toScanQRCode = () => {
router.push('/mobile/mobileMy/scanQRcode')
}
const toMyInfo = () => {
router.push('/mobile/my')
}
const handleBack = async () => {
// const result = await invoke('plugin:hula|ping', {
// payload: { value: 'hello world' }
// })
// console.log('插件测试结果:', result)
// TODO 返回上一页
router.back()
console.log('返回')
}
</script>
<style lang="scss" scoped>
$text-font-size-base: 14px;
$font-family-system: -apple-system, BlinkMacSystemFont;
$font-family-windows: 'Segoe UI', 'Microsoft YaHei';
$font-family-chinese: 'PingFang SC', 'Hiragino Sans GB';
$font-family-sans: 'Helvetica Neue', Helvetica, Arial, sans-serif;
.text-bold-style {
font-size: 14px;
font-family: $font-family-system, $font-family-windows, $font-family-sans;
color: #757775;
}
</style>

View File

@@ -42,11 +42,11 @@
</div>
</template>
<script setup lang="ts">
import CommunityContent from '@/mobile/components/community/CommunityContent.vue'
import CommunityTab from '@/mobile/components/community/CommunityTab.vue'
import PersonalInfo from '@/mobile/components/my/PersonalInfo.vue'
import Settings from '@/mobile/components/my/Settings.vue'
import SafeAreaPlaceholder from '@/mobile/components/placeholders/SafeAreaPlaceholder.vue'
import CommunityContent from '#/components/community/CommunityContent.vue'
import CommunityTab from '#/components/community/CommunityTab.vue'
import PersonalInfo from '#/components/my/PersonalInfo.vue'
import Settings from '#/components/my/Settings.vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import router from '@/router'
const toPublishCommunity = () => {

View File

@@ -1,3 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import { type } from '@tauri-apps/plugin-os'
import {
createRouter,
@@ -6,205 +7,213 @@ import {
type RouteLocationNormalized,
type RouteRecordRaw
} from 'vue-router'
import ChatRoomLayout from '#/layout/chat-room/ChatRoomLayout.vue'
import NoticeLayout from '#/layout/chat-room/NoticeLayout.vue'
import FriendsLayout from '#/layout/friends/FriendsLayout.vue'
import MobileHome from '#/layout/index.vue'
import MyLayout from '#/layout/my/MyLayout.vue'
import MobileLogin from '#/login.vue'
import ChatMain from '#/views/chat-room/ChatMain.vue'
import ChatSetting from '#/views/chat-room/ChatSetting.vue'
import NoticeDetail from '#/views/chat-room/notice/NoticeDetail.vue'
import NoticeEdit from '#/views/chat-room/notice/NoticeEdit.vue'
import NoticeList from '#/views/chat-room/notice/NoticeList.vue'
import MobileCommunity from '#/views/community/index.vue'
import AddFriends from '#/views/friends/AddFriends.vue'
import MobileFriendPage from '#/views/friends/index.vue'
import StartGroupChat from '#/views/friends/StartGroupChat.vue'
import MobileMessagePage from '#/views/message/index.vue'
import EditBio from '#/views/my/EditBio.vue'
import EditBirthday from '#/views/my/EditBirthday.vue'
import EditProfile from '#/views/my/EditProfile.vue'
import MobileMy from '#/views/my/index.vue'
import MobileQRCode from '#/views/my/MobileQRCode.vue'
import MobileSettings from '#/views/my/MobileSettings.vue'
import MyMessages from '#/views/my/MyMessages.vue'
import MyQRCode from '#/views/my/MyQRCode.vue'
import PublishCommunity from '#/views/my/PublishCommunity.vue'
import Share from '#/views/my/Share.vue'
import SimpleBio from '#/views/my/SimpleBio.vue'
import { TauriCommand } from '@/enums'
/**! 创建窗口后再跳转页面就会导致样式没有生效所以不能使用懒加载路由的方式,有些页面需要快速响应的就不需要懒加载 */
const { BASE_URL } = import.meta.env
const isMobile = type() === 'ios' || type() === 'android'
// 移动端路由配置 - 保持原始的非懒加载设计以避免样式和性能问题
const getMobileRoutes = async (): Promise<Array<RouteRecordRaw>> => {
// 动态导入移动端组件,但立即解析以避免懒加载问题
const [
{ default: ChatRoomLayout },
{ default: NoticeLayout },
{ default: MobileHome },
{ default: MyLayout },
{ default: MobileLogin },
{ default: ChatMain },
{ default: ChatSetting },
{ default: NoticeDetail },
{ default: NoticeEdit },
{ default: NoticeList },
{ default: MobileCommunity },
{ default: MobileFriendPage },
{ default: MobileMessagePage },
{ default: EditBio },
{ default: EditBirthday },
{ default: EditProfile },
{ default: MobileMy },
{ default: MobileQRCode },
{ default: MobileSettings },
{ default: MyMessages },
{ default: MyQRCode },
{ default: PublishCommunity },
{ default: Share }
] = await Promise.all([
import('@/mobile/layout/chat-room/ChatRoomLayout.vue'),
import('@/mobile/layout/chat-room/NoticeLayout.vue'),
import('@/mobile/layout/index.vue'),
import('@/mobile/layout/my/MyLayout.vue'),
import('@/mobile/login.vue'),
import('@/mobile/views/chat-room/ChatMain.vue'),
import('@/mobile/views/chat-room/ChatSetting.vue'),
import('@/mobile/views/chat-room/notice/NoticeDetail.vue'),
import('@/mobile/views/chat-room/notice/NoticeEdit.vue'),
import('@/mobile/views/chat-room/notice/NoticeList.vue'),
import('@/mobile/views/community/index.vue'),
import('@/mobile/views/friends/index.vue'),
import('@/mobile/views/message/index.vue'),
import('@/mobile/views/my/EditBio.vue'),
import('@/mobile/views/my/EditBirthday.vue'),
import('@/mobile/views/my/EditProfile.vue'),
import('@/mobile/views/my/index.vue'),
import('@/mobile/views/my/MobileQRCode.vue'),
import('@/mobile/views/my/MobileSettings.vue'),
import('@/mobile/views/my/MyMessages.vue'),
import('@/mobile/views/my/MyQRCode.vue'),
import('@/mobile/views/my/PublishCommunity.vue'),
import('@/mobile/views/my/Share.vue')
])
return [
{
path: '/mobile/login',
name: 'mobileLogin',
component: MobileLogin
},
{
path: '/mobile/chatRoom',
name: 'mobileChatRoom',
component: ChatRoomLayout, // 这里不能懒加载,不然会出现布局问题
children: [
{
path: '',
name: 'mobileChatRoomDefault',
redirect: '/mobile/chatRoom/chatMain'
},
{
path: 'chatMain/:roomName',
name: 'mobileChatMain',
component: ChatMain
},
{
path: 'setting',
name: 'mobileChatSetting',
component: ChatSetting
},
{
path: 'notice',
name: 'mobileChatNotice',
component: NoticeLayout,
children: [
{
path: '',
name: 'mobileChatNoticeList',
component: NoticeList
},
{
path: 'edit/:noticeId',
name: 'mobileChatNoticeEdit',
component: NoticeEdit
},
{
path: 'detail/:noticeId',
name: 'mobileChatNoticeDetail',
component: NoticeDetail
}
]
}
]
},
{
path: '/mobile/home',
name: 'mobileHome',
component: MobileHome,
children: [
{
path: '',
name: 'mobileMessage',
redirect: '/mobile/message'
},
{
path: '/mobile/message',
name: 'mobileMessage',
component: MobileMessagePage
},
{
path: '/mobile/friends',
name: 'mobileFriends',
component: MobileFriendPage
},
{
path: '/mobile/community',
name: 'mobileCommunity',
component: MobileCommunity
},
{
path: '/mobile/my',
name: 'mobileMy',
component: MobileMy
}
]
},
{
path: '/mobile/mobileMy',
name: 'mobileMyLayout',
component: MyLayout,
children: [
{
path: '',
name: 'mobileMyDefault',
redirect: '/mobile/mobileMy/editProfile'
},
{
path: 'editProfile',
name: 'mobileEditProfile',
component: EditProfile
},
{
path: 'myMessages',
name: 'mobileMyMessages',
component: MyMessages
},
{
path: 'editBio',
name: 'mobileEditBio',
component: EditBio
},
{
path: 'editBirthday',
name: 'mobileEditBirthday',
component: EditBirthday
},
{
path: 'publishCommunity',
name: 'mobilePublishCommunity',
component: PublishCommunity
},
{
path: 'settings',
name: 'MobileSettings',
component: MobileSettings
},
{
path: 'scanQRCode',
name: 'mobileQRCode',
component: MobileQRCode
},
{
path: 'share',
name: 'mobileShare',
component: Share
},
{
path: 'myQRCode',
name: 'mobileShare',
component: MyQRCode
}
]
}
]
}
// 移动端路由配置 - 使用直接导入避免懒加载问题
const getMobileRoutes = (): Array<RouteRecordRaw> => [
{
path: '/',
name: 'mobileRoot',
redirect: '/mobile/login'
},
{
path: '/mobile/login',
name: 'mobileLogin',
component: MobileLogin
},
{
path: '/mobile/chatRoom',
name: 'mobileChatRoom',
component: ChatRoomLayout,
children: [
{
path: '',
name: 'mobileChatRoomDefault',
redirect: '/mobile/chatRoom/chatMain'
},
{
path: 'chatMain',
name: 'mobileChatMain',
component: ChatMain
},
{
path: 'setting',
name: 'mobileChatSetting',
component: ChatSetting
},
{
path: 'notice',
name: 'mobileChatNotice',
component: NoticeLayout,
children: [
{
path: '',
name: 'mobileChatNoticeList',
component: NoticeList
},
{
path: 'edit[NO]ticeId',
name: 'mobileChatNoticeEdit',
component: NoticeEdit
},
{
path: 'detail[NO]ticeId',
name: 'mobileChatNoticeDetail',
component: NoticeDetail
}
]
}
]
},
{
path: '/mobile/home',
name: 'mobileHome',
component: MobileHome,
children: [
{
path: '',
name: 'mobileHomeDefault',
redirect: '/mobile/message'
},
{
path: '/mobile/message',
name: 'mobileMessage',
component: MobileMessagePage
},
{
path: '/mobile/friends',
name: 'mobileFriends',
component: MobileFriendPage
},
{
path: '/mobile/community',
name: 'mobileCommunity',
component: MobileCommunity
},
{
path: '/mobile/my',
name: 'mobileMy',
component: MobileMy
}
]
},
{
path: '/mobile/mobileMy',
name: 'mobileMyLayout',
component: MyLayout,
children: [
{
path: '',
name: 'mobileMyDefault',
redirect: '/mobile/mobileMy/editProfile'
},
{
path: 'editProfile',
name: 'mobileEditProfile',
component: EditProfile
},
{
path: 'myMessages',
name: 'mobileMyMessages',
component: MyMessages
},
{
path: 'editBio',
name: 'mobileEditBio',
component: EditBio
},
{
path: 'editBirthday',
name: 'mobileEditBirthday',
component: EditBirthday
},
{
path: 'publishCommunity',
name: 'mobilePublishCommunity',
component: PublishCommunity
},
{
path: 'settings',
name: 'MobileSettings',
component: MobileSettings
},
{
path: 'scanQRCode',
name: 'mobileQRCode',
component: MobileQRCode
},
{
path: 'share',
name: 'mobileShare',
component: Share
},
{
path: 'myQRCode',
name: 'mobileMyQRCode',
component: MyQRCode
},
{
path: 'SimpleBio',
name: 'mobileSimpleBio',
component: SimpleBio
}
]
},
{
path: '/mobile/mobileFriends',
name: 'mobileFriendsLayout',
component: FriendsLayout,
children: [
{
path: '',
name: 'mobileMyDefault',
redirect: '/mobile/mobileFriends/addFriends'
},
{
path: 'addFriends',
name: 'mobileAddFriends',
component: AddFriends
},
{
path: 'startGroupChat',
name: 'mobileStartGroupChat',
component: StartGroupChat
}
]
}
]
// 桌面端路由配置
const getDesktopRoutes = (): Array<RouteRecordRaw> => [
@@ -415,47 +424,57 @@ const getCommonRoutes = (): Array<RouteRecordRaw> => [
}
]
// 创建基础路由(通用路由 + 桌面端路由,如果是桌面端的话
const baseRoutes: Array<RouteRecordRaw> = [...getCommonRoutes(), ...(!isMobile ? getDesktopRoutes() : [])]
// 创建所有路由(通用路由 + 平台特定路由
const getAllRoutes = (): Array<RouteRecordRaw> => {
const commonRoutes = getCommonRoutes()
if (isMobile) {
return [...commonRoutes, ...getMobileRoutes()]
} else {
return [...commonRoutes, ...getDesktopRoutes()]
}
}
// 创建路由
const router: any = createRouter({
history: createWebHistory(BASE_URL),
routes: baseRoutes
routes: getAllRoutes()
})
// 移动端异步加载路由
if (isMobile) {
getMobileRoutes().then((mobileRoutes) => {
mobileRoutes.forEach((route) => {
router.addRoute(route)
})
})
}
// 在创建路由后,添加全局前置守卫
// 为解决 “已声明to但从未读取其值” 的问题,将 to 参数改为下划线开头表示该参数不会被使用
router.beforeEach((_to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext) => {
// 如果是桌面端,直接放行
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
console.log('进入了路由:', from, to)
// 桌面端直接放行
if (!isMobile) {
return next()
}
// const token = localStorage.getItem('TOKEN')
// const isLoginPage = to.path === '/mobile/login'
try {
const tokens = await invoke<{ token: string | null; refreshToken: string | null }>(TauriCommand.GET_USER_TOKENS)
const isLoginPage = to.path === '/mobile/login'
const isLoggedIn = !!(tokens.token && tokens.refreshToken)
// // 已登录用户访问登录页时重定向到首页
// if (isLoginPage && token) {
// return next('/mobile/home')
// }
// 未登录且不是登录页 → 跳转登录
if (!isLoggedIn && !isLoginPage) {
console.error('没有登录')
return next('/mobile/login')
}
// // 未登录用户访问登录页时重定向到登录
// if (!isLoginPage && !token) {
// return next('/mobile/login')
// }
// 已登录且访问登录页 → 跳转首
if (isLoggedIn && isLoginPage) {
return next('/mobile/message')
}
// 其他情况正常放行
next()
// 其他情况直接放行
return next()
} catch (error) {
console.error('获取token错误:', error)
// 出错时也跳转登录页(避免死循环)
if (to.path !== '/mobile/login') {
return next('/mobile/login')
}
return next()
}
})
export default router

View File

@@ -286,7 +286,9 @@ class RustWebSocketClient {
const listenerIndex = this.listenerController.size
this.listenerController.add(
await listen('ws-receive-message', (event: any) => {
console.log('[测试]收到消息:', event)
info(`[ws]收到消息[监听器${listenerIndex}]: ${JSON.stringify(event.payload)}`)
// debugger
useMitt.emit(WsResponseMessageType.RECEIVE_MESSAGE, event.payload)
})
)

View File

@@ -301,7 +301,7 @@ export const useChatStore = defineStore(
sessionOptions.isLoading = true
console.log('获取会话列表')
const response: any = await invokeWithErrorHandler(TauriCommand.LIST_CONTACTS, undefined, {
customErrorMessage: '获取会话列表失败22',
customErrorMessage: '获取会话列表失败',
errorType: ErrorType.Network
}).catch(() => {
sessionOptions.isLoading = false

View File

@@ -86,6 +86,12 @@ export const useMobileStore = defineStore(StoresEnum.MOBILE, () => {
})
}
const currentChatRoom: any = ref({})
const updateCurrentChatRoom = (item: any) => {
currentChatRoom.value = item
}
return {
safeArea,
updateSafeArea,
@@ -95,6 +101,8 @@ export const useMobileStore = defineStore(StoresEnum.MOBILE, () => {
isFullScreen,
keyboardDetail,
updateKeyboardDetail,
updateKeyboardState
updateKeyboardState,
currentChatRoom,
updateCurrentChatRoom
}
})

View File

@@ -16,6 +16,12 @@
}
}
:root {
user-select: none !important;
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
}
/* 移动端特定样式 */
/* Naive UI 消息组件安全区域适配 */
.n-message-container.n-message-container--top {

View File

@@ -48,6 +48,7 @@ declare module 'vue' {
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']