Skip to content

问诊室消息收发(四) ​

本页对应 问诊室消息收发,共 6 段视频,约 58 分钟。

学习目标 ​

  1. 能够将订单详情中的 status、statusValue、countDown 通过 props 传入问诊状态组件,并使用 v-if 按状态值渲染咨询中、待接诊、已完成等不同状态内容。
  2. 能够分析消息列表分段规则,说明“发送消息后刷新页面”与消息段生成的关系,并区分“仅刷新”或“仅发消息”不会新增分段。
  3. 能够遍历后端返回的嵌套消息数组,在每段消息前插入一条时间类型消息,构造并渲染消息分段显示所需的数据结构。
  4. 能够使用 scroll-view 的下拉刷新配置与 refresher-refresh 事件实现下拉交互,并通过 refresher-triggered 在请求完成后关闭下拉动画。
  5. 能够调用历史消息接口,以订单 ID 和上一次时间节点作为分页依据获取历史消息,并更新下一次请求时间节点。
  6. 能够使用 unshift 将历史消息插入列表前部,并在返回数据为空时提示“没有更多历史消息”。

知识点 ​

一、订单状态数据渲染:先明确要传哪些字段 ​

为什么需要:问诊订单在不同阶段要显示不同内容,比如“待接诊”“咨询中”“已完成”。如果只渲染一套静态结构,状态变化时页面不会跟着变。

怎么想:先看订单详情数据 orderDetail。转写中明确提到三个关键字段:status 表示状态值,statusValue 表示对应文字,countDown 表示咨询中的倒计时秒数。状态值对应关系为:2 待接诊、3 咨询中、4 已完成。

怎么做:在页面中向问诊组件传参,关键片段如下:

html
<room
  :status="orderDetail.status"
  :status-value="orderDetail.statusValue"
  :count-down="orderDetail.countDown"
/>

传完后在组件里通过 props 接收,再把 statusValue 放到文字位置,把 countDown 放到倒计时位置。状态切换用 v-if:props.status === 3 显示咨询中,=== 4 显示已完成,=== 2 显示待接诊。

易错点:转写中调试时倒计时没出来,最后发现是 status 相关拼写写错;另外 v-if 的条件值要和接口状态值一致。图标替换不影响功能,可后调。

二、消息为什么要分段:刷新页面与发消息共同决定 ​

为什么需要:聊天列表如果把所有 items 直接展开,就看不出“哪一段是上次聊的、哪一段是这次聊的”。日常聊天工具会在每段消息前显示时间。

怎么想:转写中通过反复测试得出结论:只发消息不刷新,不分段;只刷新不发消息,也不分段;发过消息后再刷新页面,才会产生新的消息段。原因是刷新后 socket 连接和请求会重新走一遍,后端按段返回 date 数组,每个单元里有 items 和 createTime。

怎么做:遍历 date,在每一段 items 前插入一条“时间通知”消息,类型为 31,内容用该段 createTime,再把 items 展开追加,最后统一赋给消息列表。关键逻辑:

js
const tempList = []
date.forEach(({ createTime, items }) => {
  tempList.push({ message: 31, content: createTime, id: createTime })
  tempList.push(...items)
})
messageList.value = tempList

易错点:createTime 要和 items 一起解构,否则会报未定义;tempList 要放在循环外,最后再赋值;历史消息倒序显示时用 unshift,不要用 push。

三、历史消息下拉交互:先做动画,再发请求 ​

为什么需要:聊天记录多时,后端一次只返回约 20 条。用户往下拉应加载更早的历史消息,而不是一次性全部加载。

怎么想:先用 scroll-view 的下拉刷新能力做交互。转写中明确用到 refresher-enabled 开启下拉,@refresherrefresh 监听下拉事件,refresher-triggered 控制 loading 是否关闭。先用 setTimeout 模拟请求耗时,验证动画能关闭,再接入真实请求。

怎么做:下拉时把下拉状态设为 true,请求结束后设为 false。注意关闭动画要放在请求成功回调里,不是下拉函数里立刻关闭。转写中是在 chatMessageList 返回数据的地方关闭动画。

易错点:refresherrefresh 事件名容易写错;忘记关闭 refresher-triggered 会导致 loading 一直转;模拟定时器只是测试代码,真实请求后要删除。

四、下拉获取历史消息与分页时间点 ​

为什么需要:只请求不更新分页参数,每次都会拿到同一批 20 条,数据重复。

怎么想:这个接口没有页码,而是用时间节点当“页码”。第一次用当前时间 lastTime 往前取 20 条;下一次用本次返回 date[0].createTime 作为新的 lastTime,继续往前取。转写中强调:每请求一次,就要更新下一次请求的时间点。

怎么做:调用 getChatMessageList(20, lastTime.value, orderId)。lastTime 初始用 dayjs 格式化为当前时间。请求成功后在消息列表回调里判断 date,遍历时取 index === 0 的 createTime 更新 lastTime。若返回空数组,提示“没有更多历史消息”并 return。

易错点:不更新 lastTime 会重复数据;空数据不 return 会继续追加;历史消息要 unshift 到顶部,否则顺序反了。转写开头还提到发消息后自动滚动到底部的问题,因要兼容小程序和 App,暂未找到理想方案,先保留。

一、先明确:消息为什么要分段显示 ​

为什么需要:聊天列表如果从头到尾一条接一条铺开,用户很难判断“哪几条是同一时间段说的”。日常聊天工具都会在消息段前插入时间提示,让阅读有节奏。本节要解决的就是:让优医咨询的问诊聊天列表也具备这种时间段显示。

怎么想:不要一上来就在前端按时间差硬切。转写稿里先验证了后端返回结构:messageList.data 是数组,每个单元是一段,每段里有 items。分段不是前端随便算出来的,而是和“发消息后刷新页面”有关。结论要记牢:发过一次消息,再刷新页面,就会多一段;只刷新不发消息不会新增段;只发消息不刷新也不会新增段;发很多条再刷新一次,仍然只产生一段。

怎么做:先承认后端已经按段返回,前端要做的是“保留段”,而不是把段拆平。每段数据形如:一段里有若干 items,并且这一段带有 createTime。我们最终要在每段 items 前面插入一条“时间消息”。

易错点:不要把“刷新几次就有几段”当成规则;真正规则是“发消息 + 刷新”才产生新段。也不要在前端自己按分钟切割,否则会偏离项目已有数据结构。

二、旧代码为什么看不出分段 ​

为什么需要:转写稿里指出,之前聊天列表虽然能显示消息,但看不出段。原因是旧代码遍历 data 后,把每个单元的 items 直接展开,全部塞进同一个数组。四段数据被展开后,视觉上就变成了一长串。

怎么想:先定位“分段信息在哪一步丢的”。不是样式问题,而是数据结构被拍平了。旧逻辑类似:遍历每一段,拿到 items,然后 ...items 展开合并。这样段与段之间的边界就消失了。

怎么做:保留“段”的概念。不要只得到一个扁平消息数组,而是构造一个新数组:每段先放一条时间消息,再放这一段的 items。这样渲染时自然会在每段前显示时间。

易错点:不要只给第一段加时间;不要把 items 再嵌套成二维数组;插入时间消息后仍要保证原消息顺序不变。

三、在每段前插入时间消息 ​

为什么需要:分段边界需要一个可见的“时间节点”。转写稿的方案是构造一条通知类型消息,内容就是这一段的 createTime,类型按项目里时间消息的枚举写 31(以实际项目字段为准)。

怎么想:遍历 data,每个单元都能解构出 items 和 createTime。先把时间消息 push 进临时数组,再把 ...items 展开 push 进去。遍历结束后,把临时数组赋给 messageList。

怎么做:示意代码如下,字段名以项目实际为准:

js
const tempList = []
data.forEach(item => {
  const { items, createTime } = item
  tempList.push(
    {
      messageType: 31, // 时间通知消息,按项目枚举
      content: createTime,
      id: createTime
    },
    ...items
  )
})
messageList.value = tempList

易错点:转写稿调试时出现过 createTime is not defined,原因是只解构了 items,没有解构同级的 createTime。另外,tempList 的赋值要放在循环外,不能每轮覆盖;push 时要用 ...items 展开,否则会把整个数组当成一条消息。

四、调试与历史消息分页的衔接 ​

为什么需要:消息分段做好后,还要和历史消息下拉衔接。下拉时请求更多历史消息,若分页时间点不更新,每次都会拿到重复数据。

怎么想:历史消息接口按时间节点往前取 20 条。第一次用当前时间;之后每次请求完,用返回 data 第 0 个单元的 createTime 更新 lastTime,相当于“下一页页码”。历史消息要倒序插到列表头部,所以用 unshift,不是 push。如果临时数组为空,说明没有更多历史消息,要提示并 return。

怎么做:下拉刷新用 refresher-enabled 和 @refresherrefresh,请求成功后在 socket 返回 messageList 的回调里关闭动画;分页时更新 lastTime,无数据时提示“没有更多历史消息”。

易错点:下拉动画不要在 setTimeout 里关,要在真正拿到数据的回调里关;历史消息不要用 push 加到尾部;没有数据时不要继续追加。

先看问题:状态渲染与自动滚动 ​

为什么需要:问诊订单有不同状态,组件要根据状态展示“待接诊、咨询中、已完成”等不同内容;聊天发消息后若能自动滚到底部会更友好,但本节明确说明:uni-app 还要兼容小程序和 App,不能简单用 DOM 操作,暂时没有理想方案,先保留问题。

怎么想:页面把订单详情中的 status、statusValue、countDown 传给消息组件。status 控制显示哪一块,statusValue 显示状态文字,countDown 给“咨询中”的倒计时使用。

怎么做:页面传参,组件用 props 接收,再用 v-if 判断:

html
<room
  :status="orderDetail.status"
  :status-value="orderDetail.statusValue"
  :count-down="orderDetail.countDown"
/>

组件中大致写:

html
<!-- 咨询中:status === 3 -->
<view v-if="status === 3">
  <text>{{ statusValue }}</text>
  <text>{{ countDown }}</text>
</view>

<!-- 待接诊:status === 2 -->
<view v-if="status === 2">{{ statusValue }}</view>

<!-- 已完成:status === 4 -->
<view v-if="status === 4">{{ statusValue }}</view>

易错点:props 名字和传参名要对应;status、statusValue 拼写要一致;状态值和文案对应关系别写反。转写中调试时就是因为变量拼写/粘贴出错,倒计时一开始没出来,改正后才正常。

消息为什么分段:刷新与发送的关系 ​

为什么需要:聊天工具通常会在消息列表里显示时间段,让用户知道哪些消息属于同一段会话。当前代码把每段 items 展开后直接放进一个数组,段信息就丢失了。

怎么想:后端返回的 date 是数组,每个单元是一段消息,每段里有 items。转写中反复验证出一个原则:只发消息不刷新,不会新增段;只刷新不发消息,也不会新增段;发过消息后再刷新页面,才会产生新的消息段。页面刷新后 socket 连接会重新建立,代码会重新走一遍。

怎么做:先观察原逻辑:遍历 date,把每段 items 展开,导致所有消息混在一起。要保留分段,就在每段 items 前插入一条“时间通知”消息。

易错点:不要误以为“刷新几次就有几段”;分段取决于发送消息与刷新的组合。原代码的展开操作本身没错,但它会让段与段之间的边界消失。

在每段前插入时间通知 ​

为什么需要:每段消息前要显示一个时间节点,才能体现“分段显示”。

怎么想:遍历 date,每遇到一段,先构造一条时间消息,再展开这一段的 items。所有内容先放进临时数组,循环结束后再合并到消息列表。历史消息是倒着取的,显示时希望从顶部插入,所以用 unshift。

怎么做:核心逻辑如下:

js
const timeList = []

date.forEach((item, index) => {
  const { createTime, items } = item

  // 每段前插入一条时间通知
  timeList.push({
    messageType: 31,
    content: createTime,
    id: createTime
  })

  // 展开当前段的消息
  timeList.push(...items)

  // 用第 0 条数据的时间更新下一次请求时间点
  if (index === 0) {
    lastTime.value = createTime
  }
})

// 历史消息从顶部插入
messageList.value.unshift(...timeList)

易错点:createTime 和 items 是平级字段,必须从每段数据中解构出来;timeList 要定义在循环外,否则会反复覆盖;插入时间消息时不要乱写字段,转写中先用 AAA:111 测试,后来改成 messageType: 31、content: createTime。

下拉交互:refresher ​

为什么需要:打开聊天界面时后端不会一次返回全部历史消息,通常一次给 20 条,需要下拉加载更多。

怎么想:用 scroll-view 的下拉刷新能力。refresher-enabled 开启下拉,@refresherrefresh 监听下拉事件,refresher-triggered 控制 loading 状态。

怎么做:

html
<scroll-view
  refresher-enabled
  :refresher-triggered="refresherTriggered"
  @refresherrefresh="onRefresh"
>
js
const refresherTriggered = ref(false)

function onRefresh() {
  refresherTriggered.value = true

  // 先模拟请求耗时,后续换成真实请求
  setTimeout(() => {
    refresherTriggered.value = false
  }, 1000)
}

易错点:事件名容易写错,建议直接复制;下拉后必须把 refresherTriggered 改回 false,否则 loading 一直存在。真实请求时,关闭动画应放在服务端返回数据的回调里,而不是随便写在请求发出后。

历史消息请求与分页 ​

为什么需要:每次请求都从同一个时间点取 20 条,数据会重复。必须像页码一样,每次更新“上一次的时间节点”。

怎么想:接口需要条数、时间节点、订单 ID。第一次以当前时间为节点往前取;拿到数据后,用返回数组第 0 条的 createTime 作为下一次请求的时间点。

怎么做:

js
// 初始时间节点:当前时间
const lastTime = ref(dayjs().format('YYYY-MM-DD HH:mm:ss'))

// 下拉时请求历史消息
getChatMessageList(20, lastTime.value, orderId)

// 服务端返回消息列表的回调中
onChatMessageList((date) => {
  const timeList = []

  date.forEach((item, index) => {
    const { createTime, items } = item

    timeList.push({
      messageType: 31,
      content: createTime,
      id: createTime
    })
    timeList.push(...items)

    if (index === 0) {
      lastTime.value = createTime
    }
  })

  if (timeList.length === 0) {
    // 提示:没有更多历史消息了
    refresherTriggered.value = false
    return
  }

  messageList.value.unshift(...timeList)
  refresherTriggered.value = false
})

易错点:不更新 lastTime 会一直拿到重复数据;历史消息要用 unshift 从顶部插入,不能直接 push 到底部;没有数据时要提示“没有更多历史消息”并 return;关闭下拉动画要放在真正拿到返回数据之后。转写中先用 setTimeout 模拟请求,再改为在 on('chat message list') 回调里关闭动画,调试过程正好说明了这一点。

代码示例 ​

示例一:问诊订单状态数据渲染(父传子 + 三种状态 + 倒计时) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>问诊订单状态渲染</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .room-status { padding: 16px; background: #fff; border: 1px solid #eee; border-radius: 8px; }
    .count-down { color: #16c79a; font-weight: bold; }
    .done-icon { color: #999; }
  </style>
</head>
<body>
<div id="app">
  <!-- 页面中向组件传数据:kebab-case 写法 -->
  <room-status
    :status="orderDetail.status"
    :status-value="orderDetail.statusValue"
    :count-down="orderDetail.countDown"
  ></room-status>
</div>

<script>
const { createApp, ref, computed, onUnmounted } = Vue

/* ===== 子组件 room-status ===== */
const RoomStatus = {
  // 1. 声明 props,模板里用 camelCase,传值用 kebab-case
  props: {
    status: { type: Number, default: 0 },
    statusValue: { type: String, default: '' },
    countDown: { type: Number, default: 0 }
  },
  template: `
    <div class="room-status">
      <!-- 状态 2:待接诊 -->
      <div v-if="status === 2">
        医生已接诊,等待 {{ statusValue }}
      </div>

      <!-- 状态 3:咨询中,需要倒计时 -->
      <div v-if="status === 3">
        咨询中,剩余 <span class="count-down">{{ timeText }}</span>
      </div>

      <!-- 状态 4:已完成 -->
      <div v-if="status === 4">
        <span class="done-icon">✔</span> 已完成 {{ statusValue }}
      </div>
    </div>
  `,
  setup(props) {
    // 2. 倒计时:用 countDown(秒数)做初始值
    const rest = ref(props.countDown)
    const timer = setInterval(() => {
      if (rest.value > 0) rest.value--
    }, 1000)
    // 3. 组件销毁时务必清除定时器
    onUnmounted(() => clearInterval(timer))

    const timeText = computed(() => {
      const m = String(Math.floor(rest.value / 60)).padStart(2, '0')
      const s = String(rest.value % 60).padStart(2, '0')
      return `${m}:${s}`
    })
    return { timeText }
  }
}

/* ===== 页面 ===== */
createApp({
  components: { RoomStatus },
  setup() {
    // 数据都来自 orderDetail
    const orderDetail = ref({
      status: 3,          // 3 = 咨询中
      statusValue: '咨询中',
      countDown: 300      // 秒
    })
    return { orderDetail }
  }
}).mount('#app')
</script>
</body>
</html>

关键行与易错点

  • 传值必须从 orderDetail 里取::status="orderDetail.status"、:status-value="orderDetail.statusValue"、:count-down="orderDetail.countDown",不要写成 :status="status"。
  • 模板写 kebab-case(status-value / count-down),props 里写 camelCase(statusValue / countDown),两者混写是最常见报错原因。
  • 三个状态用 v-if 并列控制,值分别是 2(待接诊)、3(咨询中)、4(已完成);判断用 ===,注意后端返回的是数字而不是字符串。
  • 倒计时必须在 onUnmounted 里 clearInterval,否则离开页面后定时器仍在跑。
  • 转写稿里踩过的坑:status 拼写不一致(写成 s7atus、sters 之类)会导致倒计时不显示,改动后要顺手刷新验证。

示例二:消息分段显示(在每段数据前插入一条时间节点) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>消息分段显示</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .msg-time { text-align: center; color: #999; font-size: 12px; margin: 12px 0; }
    .msg-item { padding: 8px 12px; margin: 6px 0; background: #f2f2f2; border-radius: 6px; }
  </style>
</head>
<body>
<div id="app">
  <div v-for="(item, i) in messageList" :key="i">
    <!-- msgType === 31 表示时间/通知消息 -->
    <div v-if="item.msgType === 31" class="msg-time">{{ item.content }}</div>
    <div v-else class="msg-item">{{ item.content }}</div>
  </div>
</div>

<script>
const { createApp, ref, onMounted } = Vue

createApp({
  setup() {
    const messageList = ref([])

    // 后端返回的分段数据:每段包含 createTime + items
    const data = [
      {
        createTime: '2023-10-09 18:00:00',
        items: [
          { msgType: 1, content: '你好' },
          { msgType: 1, content: '请问哪里不舒服' },
          { msgType: 2, content: '我头疼' }
        ]
      },
      {
        createTime: '2023-10-09 18:10:00',
        items: [
          { msgType: 1, content: '多久了' },
          { msgType: 2, content: '两天了' }
        ]
      },
      {
        createTime: '2023-10-09 18:20:00',
        items: [
          { msgType: 1, content: '有发烧吗' }
        ]
      }
    ]

    onMounted(() => {
      // 1. 临时数组,先收集所有消息
      const timeList = []

      data.forEach(({ createTime, items }) => {
        // 2. 每段数据前插一条时间消息,再把 items 展开
        timeList.push(
          { msgType: 31, content: createTime, id: createTime },
          ...items
        )
      })

      // 3. 循环结束后一次性赋值给 messageList
      messageList.value = timeList
    })

    return { messageList }
  }
}).mount('#app')
</script>
</body>
</html>

关键行与易错点

  • 核心逻辑是 push(时间对象, ...items):时间对象排在前面,...items 展开在同一个 push 里,最终就相当于“在每段数据头部追加了一条时间消息”。
  • createTime 必须在 forEach 的回调参数里解构(({ createTime, items })),否则会报 createTime is not defined —— 这是转写稿里实际踩到的错。
  • 不能写成 push([时间对象, ...items]),那样会塞进一个二维数组,渲染时取不到字段。
  • messageList 的赋值要放在 forEach 循环外面,循环里只往 timeList 里塞。
  • 时间消息用 msgType === 31 区分,渲染时单独走时间样式,其它消息走普通气泡样式。
  • 分段规则要理解清楚:发一次消息 + 刷新一次页面才产生一个新段;只刷新不发消息、或只发消息不刷新,都不会新增段。

示例三:下拉获取历史消息(下拉动画 + 时间节点分页 + 没有更多提示) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>下拉获取历史消息</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .refresh-bar { text-align: center; height: 40px; line-height: 40px; color: #16c79a; }
    .tip { text-align: center; color: #999; font-size: 12px; margin: 10px 0; }
    .msg-time { text-align: center; color: #999; font-size: 12px; margin: 12px 0; }
    .msg-item { padding: 8px 12px; margin: 6px 0; background: #f2f2f2; border-radius: 6px; }
  </style>
</head>
<body>
<div id="app">
  <!-- 真实 uni-app 中:
       <scroll-view scroll-y refresher-enabled
                    :refresher-triggered="isTriggered"
                    @refresherrefresh="onRefresh"> -->
  <div class="refresh-bar" v-if="isTriggered">正在加载历史消息...</div>
  <div class="tip" v-if="noMore">没有更多历史消息了</div>

  <div v-for="(item, i) in messageList" :key="i">
    <div v-if="item.msgType === 31" class="msg-time">{{ item.content }}</div>
    <div v-else class="msg-item">{{ item.content }}</div>
  </div>

  <button @click="onRefresh">↓ 下拉刷新(模拟)</button>
</div>

<script>
const { createApp, ref } = Vue

// 模拟后端:按时间节点往前取,返回分段结构
const MOCK_SEGMENTS = [
  { createTime: '2023-10-09 18:30:00', items: [{ msgType: 1, content: '消息 A1' }, { msgType: 1, content: '消息 A2' }] },
  { createTime: '2023-10-09 18:20:00', items: [{ msgType: 1, content: '消息 B1' }] },
  { createTime: '2023-10-09 18:10:00', items: [{ msgType: 1, content: '消息 C1' }, { msgType: 1, content: '消息 C2' }, { msgType: 1, content: '消息 C3' }] },
  { createTime: '2023-10-09 18:00:00', items: [{ msgType: 1, content: '消息 D1' }] }
]

// 模拟接口:以 lastTime 为界往前取(真实接口一次取 20 条)
function mockRequest(lastTime) {
  const seg = MOCK_SEGMENTS.find(s => s.createTime < lastTime)
  return seg ? [seg] : []
}

// 当前时间,格式 YYYY-MM-DD HH:mm:ss
function formatNow() {
  const d = new Date()
  const p = n => String(n).padStart(2, '0')
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
         `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}

createApp({
  setup() {
    const messageList = ref([])
    const isTriggered = ref(false)          // 下拉动画状态,初始 false
    const noMore = ref(false)               // 是否还有历史消息
    const lastTime = ref(formatNow())       // 上一次的时间节点,相当于页码
    const orderId = '1001'

    // 下拉触发
    const onRefresh = () => {
      if (noMore.value) return
      isTriggered.value = true              // 开启下拉动画
      // 真实项目:socket.emit('getChatMessageList', 20, lastTime.value, orderId)
      setTimeout(() => {
        handleData(mockRequest(lastTime.value))
      }, 500)
    }

    // 数据真正返回后的处理(对应 socket 的 chat message list 回调)
    const handleData = (data) => {
      // 1. 请求结束,关闭下拉动画
      isTriggered.value = false

      const timeList = []
      data.forEach(({ createTime, items }, index) => {
        // 2. 用第 0 条数据的时间更新下一次请求的时间节点
        if (index === 0) lastTime.value = createTime
        // 3. 每段前面插时间消息
        timeList.push({ msgType: 31, content: createTime, id: createTime }, ...items)
      })

      // 4. 没有拿到数据 → 提示没有更多,并 return
      if (timeList.length === 0) {
        noMore.value = true
        return
      }

      // 5. 历史消息是往前取的,要倒序插到头部
      messageList.value.unshift(...timeList)
    }

    return { messageList, isTriggered, noMore, onRefresh }
  }
}).mount('#app')
</script>
</body>
</html>

关键行与易错点

  • 下拉动画依赖两样东西:refresher-enabled(开启下拉能力)和 :refresher-triggered="isTriggered"(控制动画开关),isTriggered 初始值必须是 false。
  • 关闭动画的位置很容易写错:不是发请求时关,而是在数据返回的回调里关(handleData 第一行 isTriggered.value = false)。
  • 分页的“页码”就是时间节点 lastTime:用 data[0].createTime 更新,等价于“从这一段的第 0 条继续往前取 20 条”。不改这个值,每次请求拿到的都是同一批数据。
  • 历史消息是倒着取的,插入列表必须用 unshift(...timeList),用 push 会导致新拿到的旧消息跑到最底下。
  • timeList.length === 0 的判断要在 unshift 之前做并 return,否则会往列表里插一个空数组,同时提示“没有更多”。
  • 转写稿里强调:dayjs 是默认导出,import dayjs from 'dayjs' 不能加大括号,否则报未定义。
  • 数据必须“发一次消息 + 刷新一次页面”才会产生新段,所以测试分页前要先造足够多的分段数据(超过 20 条)才能看到效果。

常见错误 ​

  1. 状态组件不显示或显示错状态 → status、statusValue、countDown 字段名/大小写传错,或 v-if 状态值对应错 → 核对 props 名与订单详情字段,按 2/3/4 逐一校验分支。
  2. 倒计时不出现 → 只传了状态值,未把 countDown 传入倒计时组件或组件内未使用 → 补齐 props,并在倒计时位置替换为传入值。
  3. 聊天列表没有时间分段 → 直接遍历 date 后展开每段 items,丢失段边界 → 按段构造时间消息,再与每段 items 合并。
  4. 构造时间消息时报 createTime 未定义 → 只解构了 items,未解构同级的 createTime → 在遍历段数据时同时解构所需字段。
  5. 分段数据构造后页面不更新 → 临时数组只在循环内处理,未在循环外赋值给消息列表 → 循环结束后统一赋值给列表数据。
  6. 下拉刷新动画不关闭 → 在模拟定时器里关闭,未在真正拿到服务端消息的回调里关闭 → 在消息列表返回成功后设置 refresher-triggered = false。
  7. 下拉多次拿到重复历史消息 → 请求时间节点未更新,始终从同一时间点取数据 → 每次取到数据后,用本段第 0 条消息时间更新下一次请求时间。
  8. 历史消息顺序颠倒 → 新取到的旧消息用 push 追加到末尾 → 改为 unshift 插入到列表头部。
  9. 没有历史消息时仍继续加载/无提示 → 未判断本次返回数据是否为空 → 判断临时列表长度为 0,提示“没有更多历史消息”并 return。

练习 ​

  1. 给问诊订单状态组件补齐 status、statusValue、countDown 三个 props,并分别验证状态 2、3、4 的展示结果。
  2. 将聊天消息按 date 分段,在每一段前插入一条 type: 31 的时间消息,并确保时间取自该段第 0 条消息。
  3. 实现下拉刷新交互:开启 refresher-enabled,监听刷新事件,并在服务端消息返回后关闭刷新动画。
  4. 实现历史消息分页:每次请求后更新时间节点,使用 unshift 插入旧消息,并处理“没有更多历史消息”的空数据场景。
  5. 自测并记录:连续下拉 3 次时,消息是否重复、顺序是否正确、刷新动画是否及时关闭。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1优医咨询-问诊订单状态数据渲染10:49
2优医咨询-消息分段显示分析08:48
3优医咨询-消息分段显示12:19
4优医咨询-历史消息下拉交互07:06
5优医咨询-下拉获取历史消息10:37
6优医咨询-下拉获取历史消息分页08:21

小结 ​

  1. 状态渲染的核心是“传参正确 + 分支状态值正确 + 倒计时单独传入”。
  2. 消息分段不是简单展开数组,而是要在每段数据前显式插入时间消息。
  3. 构造分段消息时,必须同时拿到段内 items 和该段的时间字段。
  4. 下拉刷新动画的关闭时机应绑定真实数据返回,而不是模拟定时器。
  5. 历史消息分页的关键是持续更新请求时间节点,否则必然重复。
  6. 旧消息应插入列表头部,空数据要及时提示并终止后续加载。
  7. 跨端自动滚动到底部在当前方案中未妥善解决,不应作为本节验收阻塞项。