liuq пре 3 недеља
родитељ
комит
cc90e7469b
9 измењених фајлова са 191 додато и 102 уклоњено
  1. 6 3
      App.vue
  2. 38 38
      composables/useContacts.js
  3. 25 0
      composables/useUnreadBadge.js
  4. 47 15
      composables/useWebSocket.js
  5. 12 4
      pages/chat/index.vue
  6. 14 11
      pages/index/index.vue
  7. 2 0
      pages/login/index.vue
  8. 16 30
      store/chat.js
  9. 31 1
      utils/api.js

+ 6 - 3
App.vue

@@ -1,6 +1,6 @@
 <script>
 	import { getToken, setToken, getCurrentUserInfo } from './utils/api'
-	import { useWebSocket } from './composables/useWebSocket'
+	import { connectWebSocket } from './composables/useWebSocket'
 	import { setupAppNotifications } from './utils/notificationSetup'
 
 	export default {
@@ -12,8 +12,8 @@
 					return
 				}
 				await getCurrentUserInfo(token)
-				// token 有效时建立 WebSocket,保证任意页面都能实时收消息(含聊天页
-				useWebSocket(() => {}).connect()
+				// 已登录启动即建 WS;登录页 setToken 后也会 connect(reLaunch 不会再次 onLaunch
+				connectWebSocket()
 				// #ifdef APP-PLUS
 				setupAppNotifications()
 				// #endif
@@ -24,6 +24,9 @@
 		},
 		onShow: function() {
 			console.log('App Show')
+			if (getToken()) {
+				connectWebSocket()
+			}
 		},
 		onHide: function() {
 			console.log('App Hide')

+ 38 - 38
composables/useContacts.js

@@ -4,48 +4,48 @@
 import { getContacts, getToken } from '../utils/api'
 import { chatStore } from '../store/chat'
 
-export function useContacts() {
-	async function fetchContacts() {
-		const token = getToken()
-		if (!token) {
-			chatStore.contacts = []
-			return
-		}
-		chatStore.loadingContacts = true
-		try {
-			const data = await getContacts(token)
-			// 后端返回的会话列表转成前端结构,与桌面版一致:user_id, full_name, last_message, unread_count 等
-			const list = Array.isArray(data) ? data : (data.list || data.items || data.conversations || [])
-			chatStore.setContacts(
-				list.map((c) => {
-					const id = String(c.user_id ?? c.userId ?? c.id ?? '')
-					return {
-						id,
-						user_id: c.user_id ?? c.userId ?? c.id,
-						title: c.full_name ?? c.name ?? c.title ?? '未知',
-						lastMessage: typeof c.last_message === 'string'
-							? c.last_message
-							: (c.last_message && (c.last_message.content || c.last_message.text)) ?? c.last_message_preview ?? '',
-						time: c.updated_at ?? c.last_message?.created_at ?? '',
-						avatar: c.avatar ?? c.avatar_url ?? '',
-						unread: chatStore.getUnread(id),
-						// 应用 / 系统会话标记及应用信息(如果有)
-						is_system: !!c.is_system,
-						app_id: c.app_id ?? null,
-						app_name: c.app_name ?? ''
-					}
-				})
-			)
-		} catch (e) {
-			chatStore.setContacts([])
-		} finally {
-			chatStore.loadingContacts = false
-		}
+/** 供页面与 WS 等模块直接拉取会话列表(与 useContacts().fetchContacts 一致) */
+export async function fetchContactsList() {
+	const token = getToken()
+	if (!token) {
+		chatStore.contacts = []
+		return
+	}
+	chatStore.loadingContacts = true
+	try {
+		const data = await getContacts(token)
+		const list = Array.isArray(data) ? data : (data.list || data.items || data.conversations || [])
+		chatStore.setContacts(
+			list.map((c) => {
+				const id = String(c.user_id ?? c.userId ?? c.id ?? '')
+				const unreadCount = Number(c.unread_count ?? c.unreadCount ?? 0) || 0
+				return {
+					id,
+					user_id: c.user_id ?? c.userId ?? c.id,
+					title: c.full_name ?? c.name ?? c.title ?? '未知',
+					lastMessage: typeof c.last_message === 'string'
+						? c.last_message
+						: (c.last_message && (c.last_message.content || c.last_message.text)) ?? c.last_message_preview ?? '',
+					time: c.updated_at ?? c.last_message?.created_at ?? '',
+					avatar: c.avatar ?? c.avatar_url ?? '',
+					unread_count: unreadCount,
+					is_system: !!c.is_system,
+					app_id: c.app_id ?? null,
+					app_name: c.app_name ?? ''
+				}
+			})
+		)
+	} catch (e) {
+		chatStore.setContacts([])
+	} finally {
+		chatStore.loadingContacts = false
 	}
+}
 
+export function useContacts() {
 	return {
 		contacts: chatStore.contacts,
 		loadingContacts: chatStore.loadingContacts,
-		fetchContacts
+		fetchContacts: fetchContactsList
 	}
 }

+ 25 - 0
composables/useUnreadBadge.js

@@ -0,0 +1,25 @@
+/**
+ * 底部「消息」Tab 角标:统一走 GET /messages/unread-count
+ */
+import { getUnreadCount, getToken } from '../utils/api'
+import { chatStore } from '../store/chat'
+
+export async function fetchUnreadCountAndUpdateTabBar() {
+	const token = getToken()
+	if (!token) {
+		chatStore.setTabBarUnreadFromServer(0)
+		return
+	}
+	try {
+		const raw = await getUnreadCount(token)
+		const n =
+			typeof raw === 'number'
+				? raw
+				: raw != null && typeof raw === 'object'
+					? Number(raw.count ?? raw.unread_count ?? raw.total ?? 0)
+					: Number(raw) || 0
+		chatStore.setTabBarUnreadFromServer(n)
+	} catch (e) {
+		// 网络失败时保留当前角标
+	}
+}

+ 47 - 15
composables/useWebSocket.js

@@ -1,9 +1,12 @@
 /**
- * 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、本地未读、会话预览),不整表刷新
+ * 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、会话预览),不整表刷新
+ * 未读角标由 GET /messages/unread-count 刷新,不在此本地累加
  * 断线后自动重连(指数退避);主动 disconnect 时不重连
  */
-import { getToken, normalizeMessageContentType } from '../utils/api'
+import { getToken, markHistoryReadAll, normalizeMessageContentType } from '../utils/api'
 import { chatStore } from '../store/chat'
+import { fetchContactsList } from './useContacts'
+import { fetchUnreadCountAndUpdateTabBar } from './useUnreadBadge'
 
 const WS_BASE = 'wss://api.hnyunzhu.com/api/v1/ws/messages'
 const HEARTBEAT_INTERVAL = 30000
@@ -11,7 +14,6 @@ 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
@@ -26,6 +28,12 @@ function clearReconnectTimer() {
 	}
 }
 
+/** WS 心跳成功或收到推送后:拉总未读 + 会话列表 */
+function syncInboxFromServer() {
+	if (!getToken()) return
+	Promise.all([fetchUnreadCountAndUpdateTabBar(), fetchContactsList()]).catch(() => {})
+}
+
 function scheduleReconnect() {
 	if (intentionalClose) return
 	if (!getToken()) return
@@ -90,6 +98,7 @@ function attachSocketListenersOnce() {
 		reconnectAttempt = 0
 		clearReconnectTimer()
 		console.log('[WS] messages connected')
+		syncInboxFromServer()
 		heartbeatTimer = setInterval(() => {
 			try {
 				if (socketTask) uni.sendSocketMessage({ data: 'ping' })
@@ -100,28 +109,38 @@ function attachSocketListenersOnce() {
 	uni.onSocketMessage((res) => {
 		try {
 			// 心跳:服务端回复 pong
-			if (res.data === 'pong') return
+			if (res.data === 'pong') {
+				syncInboxFromServer()
+				return
+			}
 			const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+			console.log('[WS] recv', data)
+			syncInboxFromServer()
 			// 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
 			const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
-			if (!msg) return
+			if (!msg) {
+				console.log('[WS] recv (ignored: no message payload)')
+				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) {
+			if (contactId == null) {
+				console.log('[WS] recv (ignored: no contactId)', { msg, currentUserId })
+				return
+			}
+			{
 				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
+				if (hasId) {
+					console.log('[WS] recv (ignored: duplicate id)', String(normalized.id))
+					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)
@@ -154,6 +173,16 @@ function attachSocketListenersOnce() {
 					// #ifndef APP-PLUS
 					uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
 					// #endif
+				} else {
+					// 正在该会话对话框内收到新消息:标记本会话全部已读,并刷新未读与列表
+					const t = getToken()
+					if (t) {
+						Promise.resolve(markHistoryReadAll(t, cid))
+							.then(() =>
+								Promise.all([fetchUnreadCountAndUpdateTabBar(), fetchContactsList()])
+							)
+							.catch(() => {})
+					}
 				}
 				// 只更新该会话在列表中的最后一条预览与时间,不整表刷新
 				let preview = ''
@@ -171,7 +200,7 @@ function attachSocketListenersOnce() {
 				chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
 			}
 		} catch (e) {
-			console.warn('[WS] parse message error', e)
+			console.warn('[WS] parse message error', e, res && res.data)
 		}
 	})
 
@@ -224,9 +253,12 @@ export function disconnectWebSocket() {
 	performDisconnect()
 }
 
-export function useWebSocket(fetchContacts) {
-	fetchContactsRef = fetchContacts
+/** 启动已登录 / 登录成功后显式建连(与 useWebSocket().connect 相同) */
+export function connectWebSocket() {
+	tryConnect()
+}
 
+export function useWebSocket() {
 	function connect() {
 		tryConnect()
 	}

+ 12 - 4
pages/chat/index.vue

@@ -106,7 +106,8 @@ import NotificationBubble from '../../components/chat/NotificationBubble.vue'
 import { useMessages } from '../../composables/useMessages'
 import { useContacts } from '../../composables/useContacts'
 import { chatStore } from '../../store/chat'
-import { getMessageCallbackUrl, getToken } from '../../utils/api'
+import { getMessageCallbackUrl, getToken, markHistoryReadAll } from '../../utils/api'
+import { fetchUnreadCountAndUpdateTabBar } from '../../composables/useUnreadBadge'
 
 const otherUserId = ref('')
 const contactTitle = ref('会话')
@@ -157,7 +158,7 @@ function scrollToBottom() {
 	})
 }
 
-onLoad((options) => {
+onLoad(async (options) => {
 	lastBottomMsgKey.value = ''
 	otherUserId.value = String(
 		(options && (options.otherUserId || options.userId || options.contactId)) || '0'
@@ -179,9 +180,16 @@ onLoad((options) => {
 		scrollToMessageId.value = ''
 	}
 	chatStore.setActiveContact(otherUserId.value)
-	chatStore.clearUnread(otherUserId.value)
-	chatStore.updateTabBarUnreadBadge()
 	syncContactTitle()
+	try {
+		const token = getToken()
+		if (token) {
+			await markHistoryReadAll(token, otherUserId.value)
+			await fetchUnreadCountAndUpdateTabBar()
+		}
+	} catch (e) {
+		// read-all 失败仍允许查看历史
+	}
 	try {
 		const u = uni.getStorageSync('current_user')
 		if (u && typeof u === 'object') {

+ 14 - 11
pages/index/index.vue

@@ -75,6 +75,7 @@
 	import SystemAvatar from '../../components/SystemAvatar.vue'
 	import { useContacts } from '../../composables/useContacts'
 	import { useWebSocket } from '../../composables/useWebSocket'
+	import { fetchUnreadCountAndUpdateTabBar } from '../../composables/useUnreadBadge'
 	import { chatStore } from '../../store/chat'
 	import { setupAppNotifications } from '../../utils/notificationSetup'
 
@@ -97,7 +98,7 @@
 		components: { UserAvatar, SystemAvatar },
 		setup() {
 			const { fetchContacts } = useContacts()
-			const { connect } = useWebSocket(fetchContacts)
+			const { connect } = useWebSocket()
 
 			const currentUser = ref({ name: '', id: '', avatar: '', orgName: '' })
 			const refresherTriggered = ref(false)
@@ -117,12 +118,10 @@
 			}
 
 			const messageList = computed(() => {
-				// 显式依赖 unreadByContactId,保证未读变化时角标会更新
-				void chatStore.unreadByContactId
 				return (chatStore.contacts || []).map((c) => ({
 					...c,
 					time: formatTime(c.time),
-					unread: chatStore.getUnread(c.id || c.user_id)
+					unread: Number(c.unread_count ?? c.unread ?? 0) || 0
 				}))
 			})
 
@@ -135,7 +134,7 @@
 				refresherTriggered.value = true
 				try {
 					await fetchContacts()
-					chatStore.updateTabBarUnreadBadge()
+					await fetchUnreadCountAndUpdateTabBar()
 				} finally {
 					refresherTriggered.value = false
 				}
@@ -144,6 +143,7 @@
 			onMounted(() => {
 				loadCurrentUser()
 				fetchContacts()
+				fetchUnreadCountAndUpdateTabBar()
 				connect()
 				// #ifdef APP-PLUS
 				setupAppNotifications()
@@ -160,6 +160,8 @@
 				messageList,
 				loadingContacts,
 				fetchContacts,
+				fetchUnreadCountAndUpdateTabBar,
+				connect,
 				openChat,
 				goLogin,
 				currentUser,
@@ -168,14 +170,15 @@
 				onRefresh
 			}
 		},
-		onShow() {
+		async onShow() {
 			if (this.loadCurrentUser) this.loadCurrentUser()
-			if (this.fetchContacts) this.fetchContacts()
-			chatStore.updateTabBarUnreadBadge()
+			if (this.connect) this.connect()
+			if (this.fetchContacts) await this.fetchContacts()
+			if (this.fetchUnreadCountAndUpdateTabBar) await this.fetchUnreadCountAndUpdateTabBar()
 		},
-		onTabItemTap() {
-			if (this.fetchContacts) this.fetchContacts()
-			chatStore.updateTabBarUnreadBadge()
+		async onTabItemTap() {
+			if (this.fetchContacts) await this.fetchContacts()
+			if (this.fetchUnreadCountAndUpdateTabBar) await this.fetchUnreadCountAndUpdateTabBar()
 		},
 		methods: {
 			onAvatarClick() {

+ 2 - 0
pages/login/index.vue

@@ -46,6 +46,7 @@
 
 <script>
 	import { login, loginBySms, sendSmsCode, setToken, getCurrentUserInfo, getUserIdFromToken } from '../../utils/api'
+	import { connectWebSocket } from '../../composables/useWebSocket'
 
 	const USER_KEY = 'current_user'
 	const LOGIN_ACCOUNTS_KEY = 'login_saved_accounts'
@@ -202,6 +203,7 @@
 					if (!accessToken) throw new Error('登录失败:未返回 access_token')
 
 					setToken(accessToken)
+					connectWebSocket()
 
 					// 尽量补全 user 信息:优先用 login 返回的 user,否则拉 /users/me
 					let user = data.user || null

+ 16 - 30
store/chat.js

@@ -17,8 +17,16 @@ export const chatStore = reactive({
 	// 某会话是否正在加载更多
 	loadingMore: {},
 
-	// 本地未读:WS 连接后收到的新消息(别人发的且不在当前会话)按会话计数,不请求未读接口
-	unreadByContactId: {},
+	/** 登出 / 清空 token 时重置,避免换账号仍显示上一用户会话与消息 */
+	reset() {
+		this.contacts = []
+		this.loadingContacts = false
+		this.messages = {}
+		this.loadedContactIds = {}
+		this.activeContactId = ''
+		this.loadingMore = {}
+		this.setTabBarUnreadFromServer(0)
+	},
 
 	setContacts(list) {
 		this.contacts = list || []
@@ -126,35 +134,13 @@ export const chatStore = reactive({
 		this.activeContactId = contactId ? String(contactId) : ''
 	},
 
-	/** 某会话未读 +1(仅由 WS 收到新消息且非当前会话时调用) */
-	incrementUnread(contactId) {
-		const id = String(contactId)
-		const prev = this.unreadByContactId[id] ?? 0
-		this.unreadByContactId[id] = prev + 1
-	},
-
-	/** 进入会话时清零该会话本地未读 */
-	clearUnread(contactId) {
-		const id = String(contactId)
-		this.unreadByContactId[id] = 0
-	},
-
-	/** 取某会话的本地未读数,供列表展示 */
-	getUnread(contactId) {
-		if (contactId == null || contactId === '') return 0
-		return this.unreadByContactId[String(contactId)] ?? 0
-	},
-
-	/** 所有会话未读之和,供底部消息 Tab 角标 */
-	getTotalUnread() {
-		const obj = this.unreadByContactId || {}
-		return Object.values(obj).reduce((sum, n) => sum + (Number(n) || 0), 0)
-	},
-
-	/** 更新底部「消息」Tab 角标(index 0) */
-	updateTabBarUnreadBadge() {
+	/**
+	 * 底部「消息」Tab 角标(index 0),数值来自 GET /messages/unread-count
+	 * @param {number|string} n - 未读总数
+	 */
+	setTabBarUnreadFromServer(n) {
+		const total = Math.max(0, Number(n) || 0)
 		try {
-			const total = this.getTotalUnread()
 			if (total <= 0) {
 				uni.removeTabBarBadge({ index: 0 })
 			} else {

+ 31 - 1
utils/api.js

@@ -21,12 +21,17 @@ export function setToken(token) {
 		uni.setStorageSync(TOKEN_KEY, v)
 	} catch (e) {}
 	if (!v) {
-		// 与 useWebSocket 动态引用,避免 api ↔ composables 循环依赖
+		// 与 useWebSocket / chatStore 动态引用,避免 api ↔ composables 循环依赖
 		import('../composables/useWebSocket.js')
 			.then((m) => {
 				if (typeof m.disconnectWebSocket === 'function') m.disconnectWebSocket()
 			})
 			.catch(() => {})
+		import('../store/chat.js')
+			.then((m) => {
+				if (m.chatStore && typeof m.chatStore.reset === 'function') m.chatStore.reset()
+			})
+			.catch(() => {})
 	}
 }
 
@@ -182,6 +187,31 @@ export function getContacts(token) {
 	})
 }
 
+/**
+ * 未读消息总数(用于底部 Tab 角标)
+ * GET /messages/unread-count — 响应为数字
+ */
+export function getUnreadCount(token) {
+	return request({
+		token,
+		url: '/messages/unread-count',
+		method: 'GET'
+	})
+}
+
+/**
+ * 按会话标记当前用户在该会话内全部已读
+ * PUT /messages/history/{other_user_id}/read-all
+ */
+export function markHistoryReadAll(token, otherUserId) {
+	const id = encodeURIComponent(String(otherUserId))
+	return request({
+		token,
+		url: `/messages/history/${id}/read-all`,
+		method: 'PUT'
+	})
+}
+
 /**
  * 全局消息检索(后端 GET /messages/search;移动端搜索页当前用 chatStore 本地检索,可后续切换)
  * @param {string} token