diff --git a/yunkes/src/components/staffchat/ChatMain.vue b/yunkes/src/components/staffchat/ChatMain.vue index 1cb1bd1..ec7b740 100644 --- a/yunkes/src/components/staffchat/ChatMain.vue +++ b/yunkes/src/components/staffchat/ChatMain.vue @@ -41,6 +41,12 @@ const total = ref(0) /** 首次加载是否完成(msgid 定位只在已加载数据上尝试,避免误报「不在当前页」)。 */ const initialLoaded = ref(false) +// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把 +// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。 +// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。 +let loadSeq = 0 +let nameSeq = 0 + const isGroup = computed(() => props.kind === 'group') /** 是否已选定具体会话(决定是否拉消息 / 显示空态)。账号级入口(talkerId='')→ 空态。 */ const hasActive = computed(() => !!props.talkerId) @@ -62,12 +68,14 @@ const visibleMessages = computed(() => { const talkerName = ref(null) async function loadTalkerName(): Promise { + const seq = ++nameSeq talkerName.value = null if (!hasActive.value) return try { if (isGroup.value && props.talkerId) { // 群名(room_id 精确单行直查,不再拉整页群列表) const res = await getGroups({ channel: CHANNEL, room_id: props.talkerId, page: 1, page_size: 1 }) + if (seq !== nameSeq) return // 竞态:已有更新的请求,丢弃旧会话响应 talkerName.value = res.items[0]?.room_name || null } else if (props.talkerId) { // 客户昵称 @@ -78,6 +86,7 @@ async function loadTalkerName(): Promise { page: 1, page_size: 1, }) + if (seq !== nameSeq) return // 竞态:已有更新的请求,丢弃旧会话响应 const c = res.items[0] talkerName.value = c?.friend_nickname || c?.friend_remark || null } @@ -184,6 +193,7 @@ function queryFor(pageNum: number) { * returns asc, so the last page holds the newest slice. */ async function loadInitial(): Promise { + const seq = ++loadSeq highlightId.value = null // 账号级入口(无具体会话):不拉消息,清空状态,留空态给右列。 if (!hasActive.value) { @@ -193,12 +203,15 @@ async function loadInitial(): Promise { currentPage.value = 1 pendingMsgid.value = null initialLoaded.value = true + // 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态 + loading.value = false return } loading.value = true errorMsg.value = null try { const first = await getMessages(queryFor(1)) + if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应 total.value = first.total totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE)) @@ -206,16 +219,21 @@ async function loadInitial(): Promise { messages.value = first.items } else { const last = await getMessages(queryFor(totalPages.value)) + if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应 messages.value = last.items } currentPage.value = totalPages.value scrollToBottom() } catch (e) { + if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换) errorMsg.value = (e as Error).message messages.value = [] } finally { - loading.value = false - initialLoaded.value = true + // 只有最新请求才能复位加载态:被取代的旧请求不得清掉新请求的 loading + if (seq === loadSeq) { + loading.value = false + initialLoaded.value = true + } } // DOM 渲染完再尝试 msgid 定位(覆盖 scrollToBottom 的置底)。 nextTick(() => tryLocate()) diff --git a/yunkes/src/views/ChatTimeline.vue b/yunkes/src/views/ChatTimeline.vue index e38e641..d442c89 100644 --- a/yunkes/src/views/ChatTimeline.vue +++ b/yunkes/src/views/ChatTimeline.vue @@ -42,6 +42,12 @@ const currentPage = ref(1) const totalPages = ref(1) const total = ref(0) +// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把 +// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。 +// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。 +let loadSeq = 0 +let nameSeq = 0 + const channelTyped = computed(() => props.channel as Channel) /** True when this timeline is a group chat (drives the query + title). */ @@ -72,12 +78,14 @@ const visibleMessages = computed(() => { const talkerName = ref(null) async function loadTalkerName(): Promise { + const seq = ++nameSeq talkerName.value = null if (!hasActive.value) return try { if (isGroup.value && props.roomId) { // 群名(room_id 精确单行直查,不再拉整页群列表) const res = await getGroups({ channel: channelTyped.value, room_id: props.roomId, page: 1, page_size: 1 }) + if (seq !== nameSeq) return // 竞态:已有更新的请求,丢弃旧会话响应 talkerName.value = res.items[0]?.room_name || null } else if (props.talkerId) { // 客户昵称 @@ -88,6 +96,7 @@ async function loadTalkerName(): Promise { page: 1, page_size: 1, }) + if (seq !== nameSeq) return // 竞态:已有更新的请求,丢弃旧会话响应 const c = res.items[0] talkerName.value = c?.friend_nickname || c?.friend_remark || null } @@ -188,18 +197,22 @@ function queryFor(pageNum: number) { * returns asc, so the last page holds the newest slice. */ async function loadInitial(): Promise { + const seq = ++loadSeq // 账号级入口(无具体会话):不拉消息,清空状态,留空态给右侧主区。 if (!hasActive.value) { messages.value = [] total.value = 0 totalPages.value = 1 currentPage.value = 1 + // 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态 + loading.value = false return } loading.value = true errorMsg.value = null try { const first = await getMessages(queryFor(1)) + if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应 total.value = first.total totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE)) @@ -207,15 +220,18 @@ async function loadInitial(): Promise { messages.value = first.items } else { const last = await getMessages(queryFor(totalPages.value)) + if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应 messages.value = last.items } currentPage.value = totalPages.value scrollToBottom() } catch (e) { + if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换) errorMsg.value = (e as Error).message messages.value = [] } finally { - loading.value = false + // 只有最新请求才能复位加载态:被取代的旧请求不得清掉新请求的 loading + if (seq === loadSeq) loading.value = false } }