liuq 1 ماه پیش
والد
کامیت
b796d212f8
9فایلهای تغییر یافته به همراه626 افزوده شده و 288 حذف شده
  1. 4 0
      App.vue
  2. 168 102
      composables/useWebSocket.js
  3. 47 21
      pages/app-center/index.vue
  4. 28 4
      pages/chat/index.vue
  5. 5 7
      pages/index/index.vue
  6. 107 77
      pages/profile/index.vue
  7. 102 76
      pages/search-center/index.vue
  8. 59 1
      utils/api.js
  9. 106 0
      utils/notificationSetup.js

+ 4 - 0
App.vue

@@ -1,6 +1,7 @@
 <script>
 	import { getToken, setToken, getCurrentUserInfo } from './utils/api'
 	import { useWebSocket } from './composables/useWebSocket'
+	import { setupAppNotifications } from './utils/notificationSetup'
 
 	export default {
 		onLaunch: async function() {
@@ -13,6 +14,9 @@
 				await getCurrentUserInfo(token)
 				// token 有效时建立 WebSocket,保证任意页面都能实时收消息(含聊天页)
 				useWebSocket(() => {}).connect()
+				// #ifdef APP-PLUS
+				setupAppNotifications()
+				// #endif
 			} catch (e) {
 				setToken('')
 				uni.reLaunch({ url: '/pages/login/index' })

+ 168 - 102
composables/useWebSocket.js

@@ -1,14 +1,46 @@
 /**
  * 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、本地未读、会话预览),不整表刷新
+ * 断线后自动重连(指数退避);主动 disconnect 时不重连
  */
 import { getToken, normalizeMessageContentType } from '../utils/api'
 import { chatStore } from '../store/chat'
 
 const WS_BASE = 'wss://api.hnyunzhu.com/api/v1/ws/messages'
 const HEARTBEAT_INTERVAL = 30000
+const INITIAL_RECONNECT_DELAY = 1000
+const MAX_RECONNECT_DELAY = 30000
+
 let socketTask = null
 let fetchContactsRef = null
 let heartbeatTimer = null
+/** 仅 uni.onSocket* 注册一次,避免每次 connect 叠加监听 */
+let socketListenersAttached = false
+let intentionalClose = false
+let reconnectTimer = null
+let reconnectAttempt = 0
+
+function clearReconnectTimer() {
+	if (reconnectTimer) {
+		clearTimeout(reconnectTimer)
+		reconnectTimer = null
+	}
+}
+
+function scheduleReconnect() {
+	if (intentionalClose) return
+	if (!getToken()) return
+	clearReconnectTimer()
+	const delay = Math.min(
+		INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempt),
+		MAX_RECONNECT_DELAY
+	)
+	reconnectAttempt += 1
+	console.log(`[WS] reconnect scheduled in ${delay}ms (attempt ${reconnectAttempt})`)
+	reconnectTimer = setTimeout(() => {
+		reconnectTimer = null
+		tryConnect()
+	}, delay)
+}
 
 function normalizeWsMessage(raw) {
 	const m = raw.message || raw
@@ -50,123 +82,157 @@ function getContactIdFromMessage(msg, currentUserId) {
 	return receiverId
 }
 
-export function useWebSocket(fetchContacts) {
-	fetchContactsRef = fetchContacts
+function attachSocketListenersOnce() {
+	if (socketListenersAttached) return
+	socketListenersAttached = true
 
-	function connect() {
-		const token = getToken()
-		if (!token || socketTask) return
-		const url = `${WS_BASE}?token=${encodeURIComponent(token)}`
-		socketTask = uni.connectSocket({
-			url,
-			success: () => {}
-		})
-		uni.onSocketOpen(() => {
-			console.log('[WS] messages connected')
-			heartbeatTimer = setInterval(() => {
-				try {
-					if (socketTask) uni.sendSocketMessage({ data: 'ping' })
-				} catch (e) {}
-			}, HEARTBEAT_INTERVAL)
-		})
-		uni.onSocketMessage((res) => {
+	uni.onSocketOpen(() => {
+		reconnectAttempt = 0
+		clearReconnectTimer()
+		console.log('[WS] messages connected')
+		heartbeatTimer = setInterval(() => {
 			try {
-				// 心跳:服务端回复 pong
-				if (res.data === 'pong') return
-				const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
-				// 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
-				const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
-				if (!msg) return
-				const normalized = normalizeWsMessage({ message: msg })
-				// 需要当前用户 id 判断会话方:若后端推送里带 current_user_id 用那个,否则用 receiver_id 判断
-				const currentUserId = data.current_user_id ?? normalized.receiverId
-				const contactId = getContactIdFromMessage(msg, currentUserId)
-				if (contactId != null) {
-					const cid = String(contactId)
-					const list = chatStore.messages[cid] || []
-					const hasId = normalized.id && list.some((m) => String(m.id) === String(normalized.id))
-					if (hasId) return
-					chatStore.appendMessage(cid, normalized)
-					// 前台 & 后台消息通知:若当前不在该会话,则给出提示
-					const isActive = String(chatStore.activeContactId || '') === String(contactId)
-					// 别人发给我且不在当前会话:本地未读 +1,并更新底部消息 Tab 角标
-					if (!normalized.isMe && !isActive) {
-						chatStore.incrementUnread(cid)
-						chatStore.updateTabBarUnreadBadge()
-					}
-					if (!isActive) {
-						const contact = (chatStore.contacts || []).find(
-							(c) => String(c.user_id || c.id) === String(contactId)
-						)
-						const title = (contact && contact.title) || '新消息'
-						let body = ''
-						if (normalized.contentType === 'TEXT') {
-							body = normalized.content ? String(normalized.content).slice(0, 50) : ''
-						} else if (normalized.contentType === 'IMAGE') {
-							body = '[图片]'
-						} else if (normalized.contentType === 'VIDEO') {
-							body = '[视频]'
-						} else if (normalized.contentType === 'USER_NOTIFICATION') {
-							body = normalized.title
-								? String(normalized.title).slice(0, 50)
-								: normalized.content
-									? String(normalized.content).slice(0, 50)
-									: '[通知]'
-						} else {
-							body = normalized.title || '[文件]'
-						}
-						// #ifdef APP-PLUS
-						try {
-							plus.push.createMessage(body || '您有一条新消息', { contactId }, { title })
-						} catch (e) {
-							// 兜底为 Toast
-							uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
-						}
-						// #endif
-						// #ifndef APP-PLUS
-						uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
-						// #endif
-					}
-					// 只更新该会话在列表中的最后一条预览与时间,不整表刷新
-					let preview = ''
+				if (socketTask) uni.sendSocketMessage({ data: 'ping' })
+			} catch (e) {}
+		}, HEARTBEAT_INTERVAL)
+	})
+
+	uni.onSocketMessage((res) => {
+		try {
+			// 心跳:服务端回复 pong
+			if (res.data === 'pong') return
+			const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+			// 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
+			const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
+			if (!msg) return
+			const normalized = normalizeWsMessage({ message: msg })
+			// 需要当前用户 id 判断会话方:若后端推送里带 current_user_id 用那个,否则用 receiver_id 判断
+			const currentUserId = data.current_user_id ?? normalized.receiverId
+			const contactId = getContactIdFromMessage(msg, currentUserId)
+			if (contactId != null) {
+				const cid = String(contactId)
+				const list = chatStore.messages[cid] || []
+				const hasId = normalized.id && list.some((m) => String(m.id) === String(normalized.id))
+				if (hasId) return
+				chatStore.appendMessage(cid, normalized)
+				// 前台 & 后台消息通知:若当前不在该会话,则给出提示
+				const isActive = String(chatStore.activeContactId || '') === String(contactId)
+				// 别人发给我且不在当前会话:本地未读 +1,并更新底部消息 Tab 角标
+				if (!normalized.isMe && !isActive) {
+					chatStore.incrementUnread(cid)
+					chatStore.updateTabBarUnreadBadge()
+				}
+				if (!isActive) {
+					const contact = (chatStore.contacts || []).find(
+						(c) => String(c.user_id || c.id) === String(contactId)
+					)
+					const title = (contact && contact.title) || '新消息'
+					let body = ''
 					if (normalized.contentType === 'TEXT') {
-						preview = normalized.content ? String(normalized.content).slice(0, 50) : ''
-					} else if (normalized.contentType === 'IMAGE') preview = '[图片]'
-					else if (normalized.contentType === 'VIDEO') preview = '[视频]'
-					else if (normalized.contentType === 'USER_NOTIFICATION') {
-						preview = normalized.title
+						body = normalized.content ? String(normalized.content).slice(0, 50) : ''
+					} else if (normalized.contentType === 'IMAGE') {
+						body = '[图片]'
+					} else if (normalized.contentType === 'VIDEO') {
+						body = '[视频]'
+					} else if (normalized.contentType === 'USER_NOTIFICATION') {
+						body = normalized.title
 							? String(normalized.title).slice(0, 50)
 							: normalized.content
 								? String(normalized.content).slice(0, 50)
 								: '[通知]'
-					} else preview = normalized.title || '[文件]'
-					chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
+					} else {
+						body = normalized.title || '[文件]'
+					}
+					// #ifdef APP-PLUS
+					try {
+						plus.push.createMessage(body || '您有一条新消息', { contactId }, { title })
+					} catch (e) {
+						// 兜底为 Toast
+						uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
+					}
+					// #endif
+					// #ifndef APP-PLUS
+					uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
+					// #endif
 				}
-			} catch (e) {
-				console.warn('[WS] parse message error', e)
-			}
-		})
-		uni.onSocketError((err) => {
-			console.warn('[WS] error', err)
-		})
-		uni.onSocketClose(() => {
-			if (heartbeatTimer) {
-				clearInterval(heartbeatTimer)
-				heartbeatTimer = null
+				// 只更新该会话在列表中的最后一条预览与时间,不整表刷新
+				let preview = ''
+				if (normalized.contentType === 'TEXT') {
+					preview = normalized.content ? String(normalized.content).slice(0, 50) : ''
+				} else if (normalized.contentType === 'IMAGE') preview = '[图片]'
+				else if (normalized.contentType === 'VIDEO') preview = '[视频]'
+				else if (normalized.contentType === 'USER_NOTIFICATION') {
+					preview = normalized.title
+						? String(normalized.title).slice(0, 50)
+						: normalized.content
+							? String(normalized.content).slice(0, 50)
+							: '[通知]'
+				} else preview = normalized.title || '[文件]'
+				chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
 			}
-			socketTask = null
-		})
-	}
+		} catch (e) {
+			console.warn('[WS] parse message error', e)
+		}
+	})
 
-	function disconnect() {
+	uni.onSocketError((err) => {
+		console.warn('[WS] error', err)
+	})
+
+	uni.onSocketClose(() => {
 		if (heartbeatTimer) {
 			clearInterval(heartbeatTimer)
 			heartbeatTimer = null
 		}
-		if (socketTask) {
-			uni.closeSocket()
-			socketTask = null
+		socketTask = null
+		if (!intentionalClose && getToken()) {
+			scheduleReconnect()
 		}
+	})
+}
+
+function tryConnect() {
+	const token = getToken()
+	if (!token || socketTask) return
+	intentionalClose = false
+	attachSocketListenersOnce()
+	const url = `${WS_BASE}?token=${encodeURIComponent(token)}`
+	socketTask = uni.connectSocket({
+		url,
+		success: () => {}
+	})
+}
+
+function performDisconnect() {
+	intentionalClose = true
+	clearReconnectTimer()
+	reconnectAttempt = 0
+	if (heartbeatTimer) {
+		clearInterval(heartbeatTimer)
+		heartbeatTimer = null
+	}
+	if (socketTask) {
+		uni.closeSocket()
+		socketTask = null
+	}
+}
+
+/**
+ * 登出 / 清空 token 时关闭连接并禁止自动重连(供 setToken 等统一调用)
+ */
+export function disconnectWebSocket() {
+	performDisconnect()
+}
+
+export function useWebSocket(fetchContacts) {
+	fetchContactsRef = fetchContacts
+
+	function connect() {
+		tryConnect()
+	}
+
+	function disconnect() {
+		performDisconnect()
 	}
 
 	return { connect, disconnect }

+ 47 - 21
pages/app-center/index.vue

@@ -29,20 +29,22 @@
 			@refresherrefresh="onRefresh"
 		>
 			<!-- 最近使用标题 + 4 列网格(最多展示 8 个) -->
-			<text class="section-title">最近使用</text>
-			<view class="quick-grid">
-				<view v-for="app in quickApps" :key="app.id" class="app-tile" @click="openApp(app)">
-					<view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
-						<image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
-						<view
-							v-if="app.badge"
-							class="tile-badge"
-							:style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
-						>
-							{{ app.badge }}
+			<view class="recent-section">
+				<text class="section-title">最近使用</text>
+				<view class="quick-grid">
+					<view v-for="app in quickApps" :key="app.id" class="app-tile" @click="openApp(app)">
+						<view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
+							<image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
+							<view
+								v-if="app.badge"
+								class="tile-badge"
+								:style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
+							>
+								{{ app.badge }}
+							</view>
 						</view>
+						<text class="tile-name tile-name-2">{{ app.name }}</text>
 					</view>
-					<text class="tile-name">{{ app.name }}</text>
 				</view>
 			</view>
 
@@ -114,10 +116,10 @@
 
 <script>
 	import UserAvatar from '../../components/UserAvatar.vue'
-	import { getToken, getLaunchpadApps, ssoLogin } from '../../utils/api'
+	import { getToken, getLaunchpadApps, ssoLogin, getUserIdFromToken } from '../../utils/api'
 
 	const USER_KEY = 'current_user'
-	const RECENT_APPS_STORAGE_KEY = 'launchpad_recent_apps_v1'
+	const RECENT_APPS_KEY_PREFIX = 'launchpad_recent_apps_v1_'
 	const RECENT_APPS_MAX = 8
 
 	/** 渐变色库与 `components/SystemAvatar.vue` 保持一致 */
@@ -215,18 +217,37 @@
 					this.refresherTriggered = false
 				}
 			},
+			/** 当前登录用户 id,用于分用户存储「最近使用」 */
+			getRecentAppsStorageKey() {
+				let uid = ''
+				try {
+					const raw = uni.getStorageSync(USER_KEY)
+					if (raw && typeof raw === 'object') {
+						const id = raw.id ?? raw.user_id
+						if (id != null && id !== '') uid = String(id)
+					}
+				} catch (e) {}
+				if (!uid) {
+					const token = getToken()
+					if (token) uid = String(getUserIdFromToken(token) || '')
+				}
+				return uid ? RECENT_APPS_KEY_PREFIX + uid : ''
+			},
 			loadRecentApps() {
+				const key = this.getRecentAppsStorageKey()
+				if (!key) return []
 				try {
-					const raw = uni.getStorageSync(RECENT_APPS_STORAGE_KEY)
+					const raw = uni.getStorageSync(key)
 					if (Array.isArray(raw)) return raw
-					// 兼容旧结构(如后续你把数据包成对象)
 					if (raw && typeof raw === 'object' && Array.isArray(raw.items)) return raw.items
 				} catch (e) {}
 				return []
 			},
 			saveRecentApps(list) {
+				const key = this.getRecentAppsStorageKey()
+				if (!key) return
 				try {
-					uni.setStorageSync(RECENT_APPS_STORAGE_KEY, Array.isArray(list) ? list : [])
+					uni.setStorageSync(key, Array.isArray(list) ? list : [])
 				} catch (e) {}
 			},
 			refreshQuickApps() {
@@ -264,6 +285,7 @@
 			recordRecentApp(appId) {
 				const id = String(appId ?? '')
 				if (!id) return
+				if (!this.getRecentAppsStorageKey()) return
 				const now = Date.now()
 				const list = this.loadRecentApps()
 
@@ -487,14 +509,18 @@
 		opacity: 0.85;
 	}
 
+	/* 最近使用:整体与顶栏拉开一点距离 */
+	.recent-section {
+		padding-top: 28rpx;
+	}
+
 	/* 常用应用 4 列网格 */
 	.quick-grid {
 		display: flex;
 		flex-wrap: wrap;
 		gap: 24rpx;
 		/* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
-		padding: 24rpx 24rpx 12rpx 32rpx;
-		padding-bottom: 12rpx;
+		padding: 24rpx 24rpx 40rpx 32rpx;
 	}
 
 	.app-tile {
@@ -553,10 +579,10 @@
 		overflow: hidden;
 	}
 
-	/* 全部应用 */
+	/* 全部应用(与上方「最近使用」拉开间距) */
 	.all-section {
 		/* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
-		padding: 12rpx 24rpx 0 32rpx;
+		padding: 32rpx 24rpx 0 32rpx;
 	}
 	.section-title {
 		font-size: 34rpx;

+ 28 - 4
pages/chat/index.vue

@@ -31,6 +31,7 @@
 			<view
 				v-for="(msg, index) in messageList"
 				:key="msg.id || msg.tempId || index"
+				:id="'msg-' + (msg.id || msg.tempId)"
 			>
 				<PrivateMessageBubble
 					v-if="msg.type === 'MESSAGE' || msg.type === 'PRIVATE' || !msg.type"
@@ -112,6 +113,8 @@ const contactTitle = ref('会话')
 const fallbackContactName = ref('')
 const inputValue = ref('')
 const scrollIntoView = ref('')
+/** 从消息搜索进入时滚动到指定消息 id,定位后清空 */
+const scrollToMessageId = ref('')
 const showPlusPanel = ref(false)
 /** 正在对哪条 USER_NOTIFICATION 执行「再次提醒」(用于按钮 loading,并防并发连点) */
 const remindingNotificationId = ref('')
@@ -137,7 +140,9 @@ const messageList = computed(() => {
 const loadingMoreForContact = computed(() => !!loadingMore[String(otherUserId.value)])
 
 onLoad((options) => {
-	otherUserId.value = String((options && (options.otherUserId || options.userId)) || '0')
+	otherUserId.value = String(
+		(options && (options.otherUserId || options.userId || options.contactId)) || '0'
+	)
 	// 用联系人详情传参兜底:保证从联系人详情进来仍能显示名字
 	try {
 		const raw = options && options.contactName != null ? String(options.contactName) : ''
@@ -148,6 +153,12 @@ onLoad((options) => {
 	} catch (e) {
 		fallbackContactName.value = ''
 	}
+	try {
+		const mid = options && (options.scrollToMessageId || options.messageId)
+		if (mid != null && String(mid).trim() !== '') scrollToMessageId.value = String(mid).trim()
+	} catch (e) {
+		scrollToMessageId.value = ''
+	}
 	chatStore.setActiveContact(otherUserId.value)
 	chatStore.clearUnread(otherUserId.value)
 	chatStore.updateTabBarUnreadBadge()
@@ -178,15 +189,28 @@ onUnload(() => {
 })
 
 onMounted(() => {
-	// 滚动到底部
+	// 滚动到底部(若需定位到某条消息则交给 watch)
 	setTimeout(() => {
 		const list = messageList.value
-		if (list.length) scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
+		if (!list.length) return
+		if (scrollToMessageId.value) return
+		scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
 	}, 300)
 })
 
 watch(messageList, (list) => {
-	if (list.length) scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
+	if (!list.length) return
+	const target = scrollToMessageId.value
+	if (target) {
+		const hit = list.find((m) => String(m.id) === String(target) || String(m.tempId) === String(target))
+		if (hit) {
+			scrollIntoView.value = 'msg-' + (hit.id || hit.tempId)
+			scrollToMessageId.value = ''
+			return
+		}
+		return
+	}
+	scrollIntoView.value = 'msg-' + (list[list.length - 1].id || list[list.length - 1].tempId)
 }, { deep: true })
 
 // contacts 更新后同步聊天标题(避免从联系人详情进入仍显示“会话”)

+ 5 - 7
pages/index/index.vue

@@ -1,6 +1,6 @@
 <template>
 	<view class="message-page">
-		<!-- 自定义顶栏:头像 + 组织名 + 搜索 + 加号 -->
+		<!-- 自定义顶栏:头像 + 组织名 + 搜索 -->
 		<view class="custom-header">
 			<view class="header-left" @click="onAvatarClick">
 				<UserAvatar
@@ -19,9 +19,6 @@
 				<view class="icon-btn" @click="onSearch">
 					<image class="icon-img" src="/static/icons/search.svg" mode="aspectFit" />
 				</view>
-				<view class="icon-btn" @click="onAdd">
-					<image class="icon-img" src="/static/icons/add.svg" mode="aspectFit" />
-				</view>
 			</view>
 		</view>
 		<!-- 消息列表 -->
@@ -79,6 +76,7 @@
 	import { useContacts } from '../../composables/useContacts'
 	import { useWebSocket } from '../../composables/useWebSocket'
 	import { chatStore } from '../../store/chat'
+	import { setupAppNotifications } from '../../utils/notificationSetup'
 
 	const USER_KEY = 'current_user'
 
@@ -147,6 +145,9 @@
 				loadCurrentUser()
 				fetchContacts()
 				connect()
+				// #ifdef APP-PLUS
+				setupAppNotifications()
+				// #endif
 			})
 
 			const loadingContacts = computed(() => chatStore.loadingContacts)
@@ -182,9 +183,6 @@
 			},
 			onSearch() {
 				uni.navigateTo({ url: '/pages/search-center/index' })
-			},
-			onAdd() {
-				uni.showToast({ title: '新建', icon: 'none' })
 			}
 		}
 	}

+ 107 - 77
pages/profile/index.vue

@@ -1,8 +1,9 @@
 <template>
 	<view class="profile-page">
-		<!-- 个人信息卡片 -->
+		<view v-if="loading" class="loading-bar">同步中…</view>
+		<!-- 个人信息卡片(只读,数据来自 GET /users/me) -->
 		<view class="card">
-			<view class="row" @click="onAvatar">
+			<view class="row">
 				<text class="label">头像</text>
 				<view class="row-right">
 					<view class="avatar-wrap">
@@ -14,105 +15,136 @@
 							unit="rpx"
 						/>
 					</view>
-					<text class="arrow">&#62;</text>
 				</view>
 			</view>
-			<view class="row" @click="onName">
+			<view class="row">
 				<text class="label">姓名</text>
 				<view class="row-right">
-					<text class="value">{{ user.name || '未设置' }}</text>
-					<text class="arrow">&#62;</text>
+					<text class="value">{{ user.name || '—' }}</text>
 				</view>
 			</view>
-			<view class="row" @click="onAlias">
-				<text class="label">名</text>
+			<view class="row">
+				<text class="label">账号名</text>
 				<view class="row-right">
-					<text class="value" :class="{ placeholder: !user.alias }">{{ user.alias || '输入别名' }}</text>
-					<text class="arrow">&#62;</text>
-				</view>
-			</view>
-			<view class="row" @click="onQRCode">
-				<text class="label">我的二维码</text>
-				<view class="row-right">
-					<view class="qr-icon">
-						<view class="qr-box"><view class="qr-dot" /><view class="qr-dot" /><view class="qr-dot" /><view class="qr-dot" /></view>
-					</view>
-					<text class="arrow">&#62;</text>
-				</view>
-			</view>
-			<view class="row" @click="onSignature">
-				<text class="label">个性签名</text>
-				<view class="row-right">
-					<text class="value" :class="{ placeholder: !user.signature }">{{ user.signature || '' }}</text>
-					<text class="arrow">&#62;</text>
+					<text class="value" :class="{ placeholder: !user.alias }">{{ user.alias || '—' }}</text>
 				</view>
 			</view>
 		</view>
-		<!-- 企业信息卡片 -->
 		<view class="card">
 			<view class="row no-arrow">
 				<text class="label">企业</text>
-				<text class="value">{{ user.orgName || '飞书个人用户' }}</text>
+				<text class="value">{{ displayOrg }}</text>
 			</view>
 		</view>
+		<text class="hint">信息由服务端维护,暂不支持在应用内修改。</text>
+
+		<view class="logout-section">
+			<view class="logout-btn" @click="onLogout">退出登录</view>
+		</view>
 	</view>
 </template>
 
 <script>
 	import UserAvatar from '../../components/UserAvatar.vue'
+	import { getToken, getCurrentUserInfo, normalizeUserPayload, setToken } from '../../utils/api'
 
 	const USER_KEY = 'current_user'
+	const DEFAULT_ORG = '韫珠科技'
 
 	export default {
 		components: { UserAvatar },
 		data() {
 			return {
+				loading: false,
 				user: {
 					name: '',
 					id: '',
 					avatar: '',
 					alias: '',
-					signature: '',
-					orgName: '飞书个人用户'
+					orgName: ''
 				}
 			}
 		},
+		computed: {
+			displayOrg() {
+				return this.user.orgName || DEFAULT_ORG
+			}
+		},
 		onLoad() {
-			this.loadUser()
+			this.applyFromStorage()
 		},
 		onShow() {
-			this.loadUser()
+			this.applyFromStorage()
+			this.refreshFromServer()
 		},
 		methods: {
-			loadUser() {
+			applyFromStorage() {
 				try {
 					const raw = uni.getStorageSync(USER_KEY)
-					if (raw && typeof raw === 'object') {
+					const u = normalizeUserPayload(raw)
+					if (u) {
 						this.user = {
-							name: raw.name || raw.nickname || '',
-							id: String(raw.id ?? raw.user_id ?? ''),
-							avatar: raw.avatar || raw.avatar_url || '',
-							alias: raw.alias || '',
-							signature: raw.signature || raw.bio || '',
-							orgName: raw.org_name || raw.orgName || '飞书个人用户'
+							name: u.name,
+							id: u.id,
+							avatar: u.avatar,
+							alias: u.alias,
+							orgName: u.orgName || u.org_name || ''
 						}
 					}
 				} catch (e) {}
 			},
-			onAvatar() {
-				uni.showToast({ title: '更换头像', icon: 'none' })
-			},
-			onName() {
-				uni.showToast({ title: '修改姓名', icon: 'none' })
-			},
-			onAlias() {
-				uni.showToast({ title: '设置别名', icon: 'none' })
-			},
-			onQRCode() {
-				uni.showToast({ title: '我的二维码', icon: 'none' })
+			async refreshFromServer() {
+				const token = getToken()
+				if (!token) return
+				this.loading = true
+				try {
+					const me = await getCurrentUserInfo(token)
+					const u = normalizeUserPayload(me)
+					if (!u) return
+					const orgName = u.orgName || u.org_name || DEFAULT_ORG
+					this.user = {
+						name: u.name,
+						id: u.id,
+						avatar: u.avatar,
+						alias: u.alias,
+						orgName
+					}
+					try {
+						const prev = uni.getStorageSync(USER_KEY)
+						const base = prev && typeof prev === 'object' ? prev : {}
+						uni.setStorageSync(USER_KEY, {
+							...base,
+							...u,
+							orgName,
+							org_name: orgName
+						})
+					} catch (e) {}
+				} catch (e) {
+					// 网络失败时保留本地/缓存展示
+				} finally {
+					this.loading = false
+				}
 			},
-			onSignature() {
-				uni.showToast({ title: '个性签名', icon: 'none' })
+			onLogout() {
+				uni.showModal({
+					title: '提示',
+					content: '确定要退出登录吗?',
+					success: (res) => {
+						if (!res.confirm) return
+						setToken('')
+						try {
+							uni.removeStorageSync(USER_KEY)
+						} catch (e) {}
+						this.user = {
+							name: '',
+							id: '',
+							avatar: '',
+							alias: '',
+							orgName: ''
+						}
+						uni.reLaunch({ url: '/pages/login/index' })
+					}
+				})
 			}
 		}
 	}
@@ -127,6 +159,12 @@
 		padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
 		box-sizing: border-box;
 	}
+	.loading-bar {
+		font-size: 24rpx;
+		color: #999;
+		margin-bottom: 16rpx;
+		padding-left: 8rpx;
+	}
 	.card {
 		background: #fff;
 		border-radius: 16rpx;
@@ -169,37 +207,29 @@
 	.value.placeholder {
 		color: #999;
 	}
-	.arrow {
-		font-size: 28rpx;
-		color: #c0c0c0;
-		flex-shrink: 0;
-	}
 	.avatar-wrap {
 		display: flex;
 		align-items: center;
 		justify-content: center;
 	}
-	.qr-icon {
-		width: 48rpx;
-		height: 48rpx;
-		background: #333;
-		border-radius: 8rpx;
-		display: flex;
-		align-items: center;
-		justify-content: center;
+	.hint {
+		display: block;
+		font-size: 24rpx;
+		color: #999;
+		line-height: 1.5;
+		padding: 0 16rpx;
 	}
-	.qr-box {
-		width: 28rpx;
-		height: 28rpx;
-		display: grid;
-		grid-template-columns: 1fr 1fr;
-		grid-template-rows: 1fr 1fr;
-		gap: 4rpx;
+	.logout-section {
+		margin-top: 48rpx;
+		padding: 0 0 24rpx;
 	}
-	.qr-dot {
-		width: 100%;
-		height: 100%;
+	.logout-btn {
 		background: #fff;
-		border-radius: 2rpx;
+		border-radius: 16rpx;
+		min-height: 96rpx;
+		line-height: 96rpx;
+		text-align: center;
+		font-size: 32rpx;
+		color: #e54d42;
 	}
 </style>

+ 102 - 76
pages/search-center/index.vue

@@ -42,6 +42,37 @@
 				<text class="empty-text">请输入关键词开始搜索</text>
 			</view>
 
+			<!-- 消息(本地已加载缓存,见 searchCachedMessages) -->
+			<view id="sec-messages" class="section" v-if="keyword.trim()">
+				<view class="section-header">
+					<text class="section-title">消息</text>
+					<view class="section-arrow">></view>
+				</view>
+				<view class="section-body">
+					<view v-if="messageHits.length" class="list">
+						<view
+							v-for="h in messageHits"
+							:key="String(h.messageId || '') + '-' + String(h.peerId)"
+							class="row-item"
+							@click="openMessageHit(h)"
+						>
+							<UserAvatar
+								:name="h.peerName || '会话'"
+								:id="String(h.peerId)"
+								:src="''"
+								:size="64"
+								unit="rpx"
+							/>
+							<view class="row-main">
+								<text class="row-title">{{ h.peerName || '会话' }}</text>
+								<text class="row-sub">{{ h.preview || '' }}</text>
+							</view>
+						</view>
+					</view>
+					<view v-else class="empty-sub">{{ messageSearchEmptyText }}</view>
+				</view>
+			</view>
+
 			<!-- 联络人 -->
 			<view id="sec-contacts" class="section" v-if="keyword.trim()">
 				<view class="section-header">
@@ -71,10 +102,6 @@
 					</view>
 					<view v-else class="empty-sub">暂无匹配联系人</view>
 				</view>
-				<view class="more-row" @click="onMore('contacts')">
-					<text class="more-text">在联系人中搜索更多</text>
-					<text class="more-arrow">></text>
-				</view>
 			</view>
 
 			<!-- 应用 -->
@@ -100,25 +127,6 @@
 					</view>
 					<view v-else class="empty-sub">暂无匹配应用</view>
 				</view>
-				<view class="more-row" @click="onMore('apps')">
-					<text class="more-text">在应用中搜索更多</text>
-					<text class="more-arrow">></text>
-				</view>
-			</view>
-
-			<!-- 消息(当前仅占位,若后续有接口可替换) -->
-			<view id="sec-messages" class="section" v-if="keyword.trim()">
-				<view class="section-header">
-					<text class="section-title">消息</text>
-					<view class="section-arrow">></view>
-				</view>
-				<view class="section-body">
-					<view class="empty-sub">暂无可展示的消息搜索内容</view>
-				</view>
-				<view class="more-row" @click="onMore('messages')">
-					<text class="more-text">在消息中搜索更多</text>
-					<text class="more-arrow">></text>
-				</view>
 			</view>
 
 			<view class="bottom-spacer" />
@@ -135,6 +143,7 @@
 import UserAvatar from '../../components/UserAvatar.vue'
 import SystemAvatar from '../../components/SystemAvatar.vue'
 import { getToken, getLaunchpadApps, searchUsers, ssoLogin } from '../../utils/api'
+import { chatStore } from '../../store/chat'
 
 export default {
 	components: { UserAvatar, SystemAvatar },
@@ -146,6 +155,7 @@ export default {
 			loading: false,
 			contacts: [],
 			apps: [],
+			messageHits: [],
 			chips: [
 				{ key: 'messages', label: '消息' },
 				{ key: 'contacts', label: '联系人' },
@@ -153,6 +163,11 @@ export default {
 			]
 		}
 	},
+	computed: {
+		messageSearchEmptyText() {
+			return '暂无匹配消息(仅搜索已加载的聊天记录)'
+		}
+	},
 	onLoad(options) {
 		try {
 			const kw = options && options.keyword ? options.keyword : ''
@@ -181,6 +196,7 @@ export default {
 			if (!q) {
 				this.contacts = []
 				this.apps = []
+				this.messageHits = []
 				return
 			}
 			this.loading = true
@@ -189,44 +205,83 @@ export default {
 				if (!token) {
 					this.contacts = []
 					this.apps = []
+					this.messageHits = []
 					uni.showToast({ title: '请先登录', icon: 'none' })
 					return
 				}
 
-				// 1) 联络人
+				const [contactsRes, appsRes] = await Promise.allSettled([
+					searchUsers(token, q, 20),
+					(async () => {
+						const res = await getLaunchpadApps()
+						const items = res && Array.isArray(res.items) ? res.items : []
+						const filtered = items.filter((it) => {
+							const name = String(it.app_name || '')
+							return name.includes(q)
+						})
+						return filtered.map((it) => ({
+							id: it.app_id ?? it.id,
+							name: it.app_name || '应用',
+							categoryName: it.category_name || '分类'
+						}))
+					})()
+				])
+
 				let contacts = []
-				try {
-					const res = await searchUsers(token, q, 20)
+				if (contactsRes.status === 'fulfilled' && contactsRes.value) {
+					const res = contactsRes.value
 					if (Array.isArray(res)) contacts = res
 					else if (res && Array.isArray(res.items)) contacts = res.items
-				} catch (e) {
-					contacts = []
 				}
 				this.contacts = contacts
 
-				// 2) 应用:复用应用中心的列表接口,然后本地模糊过滤
-				let apps = []
-				try {
-					const res = await getLaunchpadApps()
-					const items = res && Array.isArray(res.items) ? res.items : []
-					const filtered = items.filter((it) => {
-						const name = String(it.app_name || '')
-						return name.includes(q)
-					})
-					// 和 app-center 页结构尽量保持一致(只用到 name/categoryName/id)
-					apps = filtered.slice(0, 8).map((it) => ({
-						id: it.app_id ?? it.id,
-						name: it.app_name || '应用',
-						categoryName: it.category_name || '分类'
-					}))
-				} catch (e) {
-					apps = []
-				}
-				this.apps = apps
+				this.apps = appsRes.status === 'fulfilled' && Array.isArray(appsRes.value) ? appsRes.value : []
+
+				this.messageHits = this.searchCachedMessages(q)
 			} finally {
 				this.loading = false
 			}
 		},
+		/** 在 chatStore.messages 中按正文/标题匹配(仅已加载会话) */
+		searchCachedMessages(q) {
+			const needle = String(q || '').trim().toLowerCase()
+			if (!needle) return []
+			const contacts = chatStore.contacts || []
+			const peerName = (id) => {
+				const c = contacts.find((x) => String(x.user_id || x.id) === String(id))
+				return (c && (c.app_name || c.title)) ? String(c.app_name || c.title) : ''
+			}
+			const hits = []
+			const byContact = chatStore.messages || {}
+			for (const pid of Object.keys(byContact)) {
+				const list = byContact[pid] || []
+				for (const msg of list) {
+					if (msg && msg.tempId) continue
+					const mid = msg.id != null ? String(msg.id) : ''
+					if (mid.startsWith('temp')) continue
+					const blob = [msg.content, msg.title].filter(Boolean).join(' ')
+					if (!blob.toLowerCase().includes(needle)) continue
+					const preview = String(msg.content || msg.title || '').trim()
+					const ts = msg.createdAt ? new Date(msg.createdAt).getTime() : 0
+					hits.push({
+						messageId: mid,
+						peerId: String(pid),
+						peerName: peerName(pid),
+						preview: preview.length > 120 ? preview.slice(0, 120) + '…' : preview,
+						_ts: ts
+					})
+				}
+			}
+			hits.sort((a, b) => b._ts - a._ts)
+			return hits.slice(0, 20).map(({ _ts, ...h }) => h)
+		},
+		openMessageHit(h) {
+			if (!h || !h.peerId) return
+			let url = '/pages/chat/index?contactId=' + encodeURIComponent(h.peerId)
+			if (h.peerName) url += '&contactName=' + encodeURIComponent(h.peerName)
+			if (h.messageId) url += '&scrollToMessageId=' + encodeURIComponent(h.messageId)
+			uni.navigateTo({ url })
+		},
 		openContact(u) {
 			const id = String(u?.id ?? '').trim()
 			if (!id) return
@@ -261,15 +316,6 @@ export default {
 			} finally {
 				uni.hideLoading()
 			}
-		},
-		onMore(key) {
-			// 当前项目尚未实现分模块“更多搜索”的详情页,这里先保持交互反馈
-			const map = {
-				contacts: '联系人',
-				apps: '应用',
-				messages: '消息'
-			}
-			uni.showToast({ title: `打开${map[key] || '更多'}搜索`, icon: 'none' })
 		}
 	}
 }
@@ -455,26 +501,6 @@ export default {
 	font-size: 26rpx;
 }
 
-.more-row {
-	margin-top: 12rpx;
-	padding: 16rpx 10rpx;
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	border-top: 1rpx solid #f3f4f6;
-}
-
-.more-text {
-	font-size: 26rpx;
-	color: #6b7280;
-}
-
-.more-arrow {
-	font-size: 30rpx;
-	color: #9ca3af;
-	font-weight: 600;
-}
-
 .badge {
 	min-width: 42rpx;
 	height: 36rpx;

+ 59 - 1
utils/api.js

@@ -16,9 +16,18 @@ export function getToken() {
 }
 
 export function setToken(token) {
+	const v = token || ''
 	try {
-		uni.setStorageSync(TOKEN_KEY, token || '')
+		uni.setStorageSync(TOKEN_KEY, v)
 	} catch (e) {}
+	if (!v) {
+		// 与 useWebSocket 动态引用,避免 api ↔ composables 循环依赖
+		import('../composables/useWebSocket.js')
+			.then((m) => {
+				if (typeof m.disconnectWebSocket === 'function') m.disconnectWebSocket()
+			})
+			.catch(() => {})
+	}
 }
 
 /**
@@ -173,6 +182,23 @@ export function getContacts(token) {
 	})
 }
 
+/**
+ * 全局消息检索(后端 GET /messages/search;移动端搜索页当前用 chatStore 本地检索,可后续切换)
+ * @param {string} token
+ * @param {string} q - 关键词
+ * @param {{ limit?: number }} [params]
+ * @returns {Promise<object>} 一般为 { items: [...] },具体字段以后端为准
+ */
+export function searchMessages(token, q, params = {}) {
+	const limit = params.limit != null ? params.limit : 20
+	const query = `q=${encodeURIComponent(String(q || '').trim())}&limit=${Number(limit) || 20}`
+	return request({
+		token,
+		url: `/messages/search?${query}`,
+		method: 'GET'
+	})
+}
+
 /**
  * 获取通知消息回调链接
  * GET /messages/{messageId}/callback-url
@@ -243,6 +269,38 @@ export async function getCurrentUserInfo(token) {
 	throw lastErr || new Error('getCurrentUserInfo failed')
 }
 
+/**
+ * 将 /users/me 或登录返回的用户对象规范为本地 current_user 结构(头像、姓名、账号名/英文名、企业等)
+ */
+export function normalizeUserPayload(raw) {
+	if (!raw || typeof raw !== 'object') return null
+	const o =
+		raw.user != null && typeof raw.user === 'object'
+			? raw.user
+			: raw.data != null && typeof raw.data === 'object'
+				? raw.data
+				: raw
+	const id = o.id ?? o.user_id
+	const accountName =
+		o.alias ??
+		o.english_name ??
+		o.englishName ??
+		o.username ??
+		o.account_name ??
+		o.account ??
+		''
+	const org =
+		o.org_name || o.orgName || o.organization_name || o.company_name || ''
+	return {
+		name: o.name || o.nickname || o.display_name || '',
+		id: id != null ? String(id) : '',
+		avatar: o.avatar || o.avatar_url || o.avatarUrl || '',
+		alias: accountName,
+		org_name: org,
+		orgName: org
+	}
+}
+
 /**
  * 解析 JWT 获取 userId(与桌面端 getUserIdFromToken 类似)
  */

+ 106 - 0
utils/notificationSetup.js

@@ -0,0 +1,106 @@
+/**
+ * App 端:通知运行时权限(Android 13+)、iOS 本地通知授权、本地推送点击跳转会话
+ */
+// #ifdef APP-PLUS
+import { getToken } from './api'
+
+let pushClickRegistered = false
+let iosAuthAsked = false
+
+function parsePushPayload(msg) {
+	if (!msg) return null
+	let payload = msg.payload
+	if (payload == null && msg.Payload != null) payload = msg.Payload
+	if (typeof payload === 'string') {
+		try {
+			payload = JSON.parse(payload)
+		} catch (e) {
+			return null
+		}
+	}
+	if (payload && typeof payload === 'object') {
+		const cid = payload.contactId != null ? payload.contactId : payload.otherUserId
+		if (cid != null && cid !== '') return String(cid)
+	}
+	return null
+}
+
+function navigateToChatByContactId(contactId) {
+	const url =
+		'/pages/chat/index?otherUserId=' + encodeURIComponent(String(contactId))
+	uni.navigateTo({
+		url,
+		fail: () => {
+			uni.reLaunch({ url })
+		}
+	})
+}
+
+function ensureAndroidPostNotifications() {
+	try {
+		const Build = plus.android.importClass('android.os.Build')
+		if (Build.VERSION.SDK_INT < 33) return
+		const main = plus.android.runtimeMainActivity()
+		const PackageManager = plus.android.importClass('android.content.pm.PackageManager')
+		if (
+			main.checkSelfPermission('android.permission.POST_NOTIFICATIONS') ===
+			PackageManager.PERMISSION_GRANTED
+		) {
+			return
+		}
+		plus.android.requestPermissions(
+			['android.permission.POST_NOTIFICATIONS'],
+			() => {},
+			(e) => console.warn('[notification] Android POST_NOTIFICATIONS', e && e.message)
+		)
+	} catch (e) {
+		console.warn('[notification] Android permission', e)
+	}
+}
+
+function ensureIOSNotificationAuth() {
+	if (iosAuthAsked) return
+	iosAuthAsked = true
+	try {
+		const UNUserNotificationCenter = plus.ios.importClass('UNUserNotificationCenter')
+		const center = UNUserNotificationCenter.currentNotificationCenter()
+		const opts = 7
+		center.requestAuthorizationWithOptionsCompletionHandler(opts, function (granted, err) {
+			if (err) console.warn('[notification] iOS authorization', err)
+		})
+	} catch (e) {
+		console.warn('[notification] iOS permission', e)
+		iosAuthAsked = false
+	}
+}
+
+function registerPushClick() {
+	if (pushClickRegistered) return
+	if (!plus.push || typeof plus.push.addEventListener !== 'function') return
+	pushClickRegistered = true
+	plus.push.addEventListener('click', (msg) => {
+		if (!getToken()) return
+		const contactId = parsePushPayload(msg)
+		if (!contactId) return
+		navigateToChatByContactId(contactId)
+	})
+}
+
+/**
+ * 在已登录场景调用:申请权限 + 注册点击(幂等)
+ */
+export function setupAppNotifications() {
+	try {
+		if (typeof plus === 'undefined' || !plus.push) return
+		if (plus.os.name === 'Android') ensureAndroidPostNotifications()
+		else if (plus.os.name === 'iOS') ensureIOSNotificationAuth()
+		registerPushClick()
+	} catch (e) {
+		console.warn('[notification] setup', e)
+	}
+}
+// #endif
+
+// #ifndef APP-PLUS
+export function setupAppNotifications() {}
+// #endif