Skip to content

病情描述与问诊室布局(一) ​

本页对应 病情描述与问诊室布局,共 7 段视频,约 1 小时 35 分。

学习目标 ​

  • 能够独立完成病情描述页面的路由与组件配置,使用单选框组件绑定患病时间、是否就诊过,并基于 Pick 定义四字段表单类型完成表单双向绑定。
  • 能够配置 w-upload 上传组件的图标、提示文字、最大张数和单张体积限制,绑定 fileList,并根据图片数量控制提示显隐。
  • 能够实现图片上传与删除业务:调用上传 API,展示上传中/成功/失败状态,上传成功后同步 form.pictures,删除时按 url 同步移除表单图片数据。
  • 能够实现病情描述保存与回显:通过计算属性控制按钮激活,使用轻提示完成校验,调用仓库函数保存病情并携带 isChange 跳转;组件挂载时检测仓库数据,确认后回显表单与图片,并配置 close-on-popstate 保证回退时弹窗正常展示。
  • 能够基于路由 query.isChange 兼容家庭档案与选择患者界面,实现患者选中样式、默认选中、编辑按钮阻止冒泡,并在点击下一步时校验并记录 patientId 后跳转支付页。
  • 能够定义预支付信息请求/响应类型与患者详情 API,在问诊支付组件挂载时调用接口获取 payInfo 和 patient 数据,并通过浏览器调试工具验证数据获取结果。

知识点 ​

先补路由与组件:让选科室后能进入病情描述 ​

为什么需要:极速问诊选完科室后,必须有一个独立页面承载病情描述,否则“下一步”没有落点。
怎么想:这是新页面,先按“路由 + 组件”最小闭环准备:新建 ConsultIllness 组件,在路由里加一条规则,地址用 consult/illness,标题写“病情描述”。
怎么做:在 consult 目录新建病情描述组件,并参照选择科室的路由写法追加规则。

ts
// router/index.ts
{
  path: '/consult/illness',
  component: () => import('@/views/consult/ConsultIllness.vue'),
  meta: { title: '病情描述' }
}

易错点:路由地址和组件路径要对应;标题别沿用“选择科室”。转写稿里“conult/als”是语音识别同音字,实际按 consult/illness 理解。

搭页面结构:导航、医生提示、表单区 ​

为什么需要:病情描述本质是表单页,先把结构摆好,后面才能逐项绑定数据。
怎么想:页面从上到下分三块:导航、医生提示、信息收集区。信息收集区先放输入框、患病时间、是否就诊过,上传组件后续再补。
怎么做:把笔记里的结构片段拷入组件,保留导航和医生提示,表单区先留出输入框与两个单选按钮位置。
易错点:结构拷贝后要检查样式类名是否成对;单选按钮区域一开始没有选项数据,页面看起来“空”是正常的。

用单选按钮生成患病时间与是否就诊过 ​

为什么需要:患病时间和是否就诊过是固定选项,用单选按钮比手输更稳定,也便于后续校验。
怎么想:每个单选按钮组件都需要 options。患病时间准备 4 项,是否就诊过准备 2 项;选项值优先从已有枚举 IllnessTime 中取,避免魔法值。
怎么做:先定义两组选项数据,再传给组件。

ts
const timeOptions = [
  { label: '一周内', value: IllnessTime.Week },
  { label: '一个月内', value: IllnessTime.Month },
  { label: '半年内', value: IllnessTime.HalfYear },
  { label: '半年以上', value: IllnessTime.More }
]
const flagOptions = [
  { label: '就诊过', value: 1 },
  { label: '没就诊过', value: 0 }
]
vue
<cp-radio-button :options="timeOptions" />
<cp-radio-button :options="flagOptions" />

易错点:没有 v-model 时选项能显示但选不中,这是转写稿里实际调试到的现象;选项值不要随手写字符串,要和枚举/接口约定一致。

定义表单类型与初始数据 ​

为什么需要:页面只收集 4 个字段,直接复用完整问诊类型会太宽,后续绑定容易混入无关字段。
怎么想:从完整 PatientConsult 中用 Pick 收缩出病情描述、患病时间、是否就诊过、图片信息 4 个字段。
怎么做:定义 ConsultIllness 类型,再用 ref 创建表单对象。未选择项给 undefined,图片先给空数组。

ts
type ConsultIllness = Pick<
  PatientConsult,
  'illnessDesc' | 'illnessTime' | 'consultFlag' | 'pictures'
>

const form = ref<ConsultIllness>({
  illnessDesc: '',
  illnessTime: undefined,
  consultFlag: undefined,
  pictures: []
})

易错点:illnessTime、consultFlag 不能用空字符串糊弄,否则后续判断“是否选择”会失真;pictures 先给 [],不要给 undefined。

双向绑定与浏览器调试 ​

为什么需要:结构、选项、类型都准备好后,必须把表单对象接到控件上,页面才真正可录入。
怎么想:输入框绑 form.illnessDesc,两个单选按钮分别绑 form.illnessTime、form.consultFlag。
怎么做:

vue
<输入框组件 v-model="form.illnessDesc" />
<cp-radio-button v-model="form.illnessTime" :options="timeOptions" />
<cp-radio-button v-model="form.consultFlag" :options="flagOptions" />

回到浏览器刷新,先点科室确认能跳到病情描述页;再输入内容、选择患病时间、选择是否就诊过,确认都能选中。
易错点:绑定路径写错时页面不报大错但数据不更新;单选按钮的 v-model 要绑表单字段,不要绑选项数组本身。

本节完成标准 ​

页面准备完成的标志是:路由能进入病情描述页,页面结构正常,两组单选按钮有选项,输入框和单选按钮都能通过 v-model 写入 form。上传组件、保存数据、回显数据属于后续小节,本节不提前展开。

先明确上传组件要解决什么问题 ​

病情描述页除了输入描述、选择患病时间、选择是否就诊过,还需要上传与病情相关的图片。这里的核心不是“放一个上传按钮”就结束,而是要让上传组件完成四件事:限制上传数量与单张体积、收集图片列表、展示上传中/成功/失败状态、在上传成功和删除图片时同步表单数据。

为什么需要单独处理?因为 Vant 的 Uploader 默认只负责“选图、预览、触发事件”,真正的上传请求、状态提示、表单同步都要业务代码完成。怎么想?可以把组件分成两层:第一层是 UI 与约束,第二层是业务数据同步。怎么做?先搭结构、修样式、配置图标文字和限制,再绑定 fileList,最后实现 after-read 与 delete 两个处理函数。

搭结构并修复默认样式 ​

为什么需要先修样式?转写稿里明确提到,直接拷贝 Uploader 结构后“和我们想要的还有一些差距”,需要把笔记中的样式补到 style 中。怎么想?先让结构和视觉接近设计稿,再处理交互,避免样式问题和业务问题混在一起排查。

模板中把上传组件放在信息收集区域下方:

vue
<van-uploader
  v-model="fileList"
  :max-count="9"
  :max-size="5 * 1024 * 1024"
  upload-icon="photo-o"
  upload-text="上传图片"
  :after-read="onAfterRead"
  @delete="onDeleteImage"
/>
<p v-if="fileList.length === 0" class="upload-tip">
  最多上传9张,每张不超过5MB
</p>

易错点:提示文字要在有图片后隐藏,转写稿使用 v-if="fileList.length === 0";否则上传后提示仍占位,布局会被挤乱。

配置图标、文字、数量与体积限制 ​

为什么需要配置?默认图标和提示文字与业务不一致。怎么想?能用组件属性解决的,不自己造轮子。怎么做?upload-icon="photo-o"、upload-text="上传图片";数量用 max-count="9";体积用 max-size="5 * 1024 * 1024",因为单位是字节。

易错点:不要把 5MB 直接写成 5;也不要忘记 max-count 只限制选择数量,不代表服务端也限制,服务端仍应校验。

用 v-model 收集图片列表 ​

为什么需要 fileList?Uploader 的预览、删除、状态都依赖这个列表。怎么想?先准备一个响应式空数组,再通过 v-model 绑定。怎么做?

ts
import { ref } from 'vue'
import type { UploaderFileListItem } from 'vant'

const fileList = ref<UploaderFileListItem[]>([])

易错点:转写稿里调试时提到“选完之后数据没马上变成1,刷新后有一张”,这类现象要结合组件内部更新时机看,不要误判为绑定失败。判断绑定是否成功,可以看 fileList 长度和页面预览是否一致。

实现上传:三种状态与接口调用 ​

为什么需要状态?用户选图后必须知道正在上传、成功还是失败。怎么想?在 after-read 中先置为上传中,再请求接口;成功置为成功并清空提示,失败置为失败并给错误提示。怎么做?先写 API:

ts
export const uploadImage = (file: File) => {
  const fd = new FormData()
  fd.append('file', file)
  return request.post<Image>('/upload', fd)
}

再写处理函数:

ts
const onAfterRead = async (
  item: UploaderFileListItem | UploaderFileListItem[]
) => {
  if (Array.isArray(item)) return
  if (!item.file) return

  item.status = 'uploading'
  item.message = '上传中...'

  try {
    const res = await uploadImage(item.file)
    item.status = 'done'
    item.message = ''
    item.url = res.data.url
    form.value.pictures.push(res.data)
  } catch {
    item.status = 'failed'
    item.message = '上传失败'
  }
}

易错点:after-read 的参数可能是单个对象,也可能是数组。转写稿通过 Array.isArray(item) 做类型守卫,再判断 item.file 是否存在。不要直接访问 item.file 而不判空。

删除图片时同步表单数据 ​

为什么删除还要写业务?Uploader 自带删除只移除 fileList 中的预览项,不会自动删除表单里的 pictures。怎么想?上传成功时给 item.url 记录唯一地址,删除时用这个 URL 过滤 form.pictures。怎么做?

ts
const onDeleteImage = (item: UploaderFileListItem) => {
  form.value.pictures = form.value.pictures.filter(
    (pic) => pic.url !== item.url
  )
}

易错点:如果上传成功时没有给 item.url 赋值,删除时就找不到对应图片。转写稿里调试删除时也是通过开发者工具观察 fileList 和 pictures 的数量是否同步。

调试与提交前检查 ​

调试时重点看三处:选择图片后请求是否 200、响应是否返回 id 与 url、form.pictures 是否增加;删除后 fileList 与 form.pictures 是否同时减少。转写稿还演示了用低速网络看“上传中”、离线看“上传失败”,这比只看成功路径更可靠。最后提交代码时可按转写稿的提交信息记录为“病情描述-上传组件/上传业务”,便于回溯。

这一节到底要解决什么 ​

病情描述页不是只放一个输入框,它要收集四类信息:病情描述、患病时间、是否就诊过、病情图片。转写稿先做页面与表单准备,再做上传组件,最后实现“上传业务”:选图后上传、展示上传中/成功/失败、把成功数据同步到表单、删除时同步删除表单数据。为什么需要:上传组件自己维护的是 fileList,而提交给后端的是 form.pictures,两者必须同步。怎么想:先让组件能选图,再让上传结果进入表单,最后处理删除。怎么做:按“组件准备 → API → 上传三状态 → 同步表单 → 删除同步”推进。易错点:不要把“页面能预览”当成“已经上传成功”,也不要把 fileList 当成最终提交数据。

上传组件准备:结构、限制与数据收集 ​

为什么需要:默认上传组件样式、图标、提示文字与业务不一致,且要限制最多 9 张、每张 5MB。怎么想:结构用 van-upload,样式单独修;用组件属性配置图标、文字、数量、体积;用 v-model 绑定 fileList 收集图片。怎么做:转写稿中配置了 upload-icon="photo-o"、upload-text="上传图片"、max-count="9"、max-size="5 * 1024 * 1024",并绑定 v-model="fileList";提示文字用 v-if="!fileList.length",有图后隐藏。易错点:max-size 单位是字节,不是 MB;fileList 只是上传组件数据,不是最终 form.pictures。

上传 API:FormData 是提交文件的关键 ​

为什么需要:选图只是本地预览,必须把 File 提交到服务器并拿到 id、url。怎么想:接口文档说明地址为 upload、方式 POST、文件字段名为 file,响应是包含 id 和 url 的 Image 对象。怎么做:按转写稿整理关键片段如下(不是完整文件):

ts
const fd = new FormData()
fd.append('file', file)
return request.post<Image>('/upload', fd)

易错点:不能直接把 File 塞进普通 JSON;字段名必须是接口约定的 file;响应类型要按 Image 接收。

上传三状态与同步 form.pictures ​

为什么需要:用户要知道图片正在传、传成功还是失败;成功后还要把服务端数据放进表单。怎么想:after-read 触发上传;上传前设 item.status = 'uploading'、item.message = '上传中...';成功设 done 并清提示;失败设 failed 并给错误提示。怎么做:转写稿中的关键逻辑是:成功时执行 form.value.pictures.push(res.data),同时给 item.url = res.data.url,为删除做准备。易错点:after-read 的 item 可能是对象也可能是数组,转写稿明确用类型守卫:数组就 return;item.file 可能不存在,也要先判断。否则访问 item.file 会报类型错误。

删除业务:组件删除不等于表单数据删除 ​

为什么需要:点击删除后组件里的图消失了,但 form.pictures 还在,提交时会多图。怎么想:删除时拿当前 item 的唯一标识去过滤表单数组。怎么做:上传成功时记录 item.url,删除处理中用:

ts
form.value.pictures = form.value.pictures.filter(
  (p) => p.url !== item.url
)

易错点:不记录 url 就无法精确删除;不要只操作 fileList;删除事件参数类型要与上传项类型一致。

保存与回显中的上传相关注意 ​

为什么需要:保存时要把 form.pictures 存入仓库;回显时既要恢复表单四字段,也要恢复上传组件。怎么想:保存前校验病情描述、患病时间、是否就诊过;回显时 form.value = { ...四个字段 },再让 fileList = pictures。怎么做:转写稿中回显图片时给 fileList 补类型,并处理 pictures 可能为 null 的情况;回退弹窗不显示时,给 Dialog 加 close-on-popstate="false"。易错点:fileList 与 pictures 类型不一致会报错;回显图片不能只回显表单。

调试顺序与同音字纠正 ​

为什么需要:上传业务状态多,按顺序调最稳。怎么想:先看选图后 fileList 是否变化,再看 Network 是否 200,再看 form.pictures 是否 push,最后测删除。怎么做:转写稿中用开发者工具看 fileList 和 pictures 数量;用 3G 看“上传中”,用离线看“上传失败”。易错点/纠正:转写稿里的“Consult are islands”应理解为 ConsultIllness,“firere list”是 fileList,“ite”是 item,“tus”是 status,“after late”是 after-read,“close on pop data”应理解为 close-on-popstate。这些同音点不纠正,代码和文档都会对不上。

代码示例 ​

代码一:病情描述页面(表单绑定 + 单选选项 + 上传业务 + 按钮激活 + 保存 + 回显) ​

vue
<!-- src/views/consult/ConsultIllness.vue -->
<template>
  <div class="consult-illness-page">
    <cp-nav-bar title="图文问诊" />

    <!-- 医生提示 -->
    <div class="illness-tip">
      <img class="img" src="@/assets/avatar-doctor.svg" alt="" />
      <div class="info">
        <p class="title">在线医生</p>
        <p class="tip">请描述你的疾病或症状、是否用药、有无过敏等情况</p>
        <p class="call">去咨询</p>
      </div>
    </div>

    <!-- 表单区域 -->
    <div class="illness-form">
      <van-field
        v-model="form.illnessDesc"
        type="textarea"
        rows="3"
        maxlength="200"
        placeholder="请详细描述您的病情,用药情况,有无过敏史等"
      />

      <div class="item">
        <p>患病时间</p>
        <van-radio-group v-model="form.illnessTime" direction="horizontal">
          <van-radio v-for="item in timeOptions" :key="item.value" :name="item.value">
            {{ item.label }}
          </van-radio>
        </van-radio-group>
      </div>

      <div class="item">
        <p>是否就诊过</p>
        <van-radio-group v-model="form.consultFlag" direction="horizontal">
          <van-radio v-for="item in flagOptions" :key="item.value" :name="item.value">
            {{ item.label }}
          </van-radio>
        </van-radio-group>
      </div>
    </div>

    <!-- 上传区域 -->
    <div class="illness-pic">
      <p>病情图片</p>
      <van-uploader
        v-model="fileList"
        upload-icon="photo-o"
        upload-text="上传图片"
        :max-count="9"
        :max-size="5 * 1024 * 1024"
        :after-read="onAfterRead"
        @delete="onDeleteImage"
      />
      <p class="tip" v-if="fileList.length === 0">
        最多可上传 9 张图片,每张大小不超过 5MB
      </p>
    </div>

    <!-- 下一步 -->
    <div class="illness-btn">
      <van-button type="primary" round block :class="{ disabled }" @click="next">
        下一步
      </van-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import type { UploaderAfterRead, UploaderFileListItem } from 'vant/lib/uploader/types'
import { IllnessTime } from '@/enums'
import { useConsultStore } from '@/stores'
import { uploadImage } from '@/api/consult'
import type { ConsultIllness } from '@/types/consult'

const router = useRouter()
const store = useConsultStore()

/* ---------- 单选按钮的选项数据 ---------- */
const timeOptions = [
  { label: '一周内', value: IllnessTime.Week },
  { label: '一月内', value: IllnessTime.Month },
  { label: '半年内', value: IllnessTime.HalfYear },
  { label: '大于半年', value: IllnessTime.More }
]
const flagOptions = [
  { label: '就诊过', value: 1 },
  { label: '没就诊过', value: 0 }
]

/* ---------- 表单数据(只取 4 个字段) ---------- */
const form = ref<ConsultIllness>({
  illnessDesc: '',
  illnessTime: undefined,
  consultFlag: undefined,
  pictures: []
})

/* ---------- 上传组件的图片列表 ---------- */
const fileList = ref<UploaderFileListItem[]>([])

/* ---------- 按钮是否禁用(只控制样式) ---------- */
const disabled = computed(() => {
  return (
    !form.value.illnessDesc ||
    form.value.illnessTime === undefined ||
    form.value.consultFlag === undefined
  )
})

/* ---------- 选择图片之后:上传 ---------- */
const onAfterRead: UploaderAfterRead = async (item) => {
  if (Array.isArray(item)) return
  if (!item.file) return

  item.status = 'uploading'
  item.message = '上传中...'
  try {
    const res = await uploadImage(item.file)
    item.status = 'done'
    item.message = ''
    item.url = res.data.url // 记录唯一标识,删除时用
    form.value.pictures?.push(res.data) // 同步到表单
  } catch {
    item.status = 'failed'
    item.message = '上传失败'
  }
}

/* ---------- 删除图片:同步删掉表单里的数据 ---------- */
const onDeleteImage = (item: UploaderFileListItem) => {
  form.value.pictures = form.value.pictures?.filter((pic) => pic.url !== item.url)
}

/* ---------- 下一步:轻提示校验 + 保存 + 跳转 ---------- */
const next = () => {
  if (!form.value.illnessDesc) {
    showToast('请输入病情描述')
    return
  }
  if (form.value.illnessTime === undefined) {
    showToast('请选择症状持续时间')
    return
  }
  if (form.value.consultFlag === undefined) {
    showToast('请选择是否就诊过')
    return
  }
  store.setIllness(form.value)
  router.push('/user/patient?isChange=1')
}

/* ---------- 组件初始化:判断仓库有没有数据,弹窗回显 ---------- */
onMounted(() => {
  if (store.consult.illnessDesc) {
    showConfirmDialog({
      title: '温馨提示',
      message: '是否恢复您之前填写的病情信息呢?',
      closeOnPopstate: false // 页面回退时不让弹窗自动关闭
    }).then(() => {
      const { illnessDesc, illnessTime, consultFlag, pictures } = store.consult
      form.value = { illnessDesc, illnessTime, consultFlag, pictures }
      fileList.value = pictures ?? [] // 图片要单独回显给上传组件
    })
  }
})
</script>

<style lang="scss" scoped>
.illness-tip {
  display: flex;
  padding: 12px;
  .img {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    margin-right: 10px;
  }
  .title {
    font-size: 15px;
    color: #16c2a3;
  }
  .tip {
    font-size: 12px;
    color: #999;
    margin: 6px 0;
  }
}
.illness-form {
  padding: 0 12px;
  .item {
    padding: 12px 0;
    border-bottom: 1px solid #eee;
    p {
      margin-bottom: 8px;
      color: #333;
    }
  }
}
.illness-pic {
  padding: 12px;
  p {
    margin-bottom: 8px;
    color: #333;
  }
  .tip {
    font-size: 12px;
    color: #999;
    margin-top: 8px;
  }
  // 修复上传组件默认样式
  :deep(.van-uploader__upload) {
    background: #f5f6f7;
    border-radius: 8px;
  }
}
.illness-btn {
  padding: 12px 16px;
  // 禁用态:只是灰色样式,按钮依然可以点击
  .disabled {
    background: #f5f6f7;
    color: #c8c9cc;
    border-color: #f5f6f7;
  }
}
</style>

要点解释与易错点

  • form 只取 illnessDesc / illnessTime / consultFlag / pictures 四个字段,类型用 Pick 从完整问诊类型里收缩,别直接复用整个大对象。
  • 最易错:illnessTime、consultFlag 的值可能是 0(没就诊过),所以禁用判断和校验都必须写 === undefined,绝不能写 !form.value.consultFlag,否则“没就诊过”会被当成“没选”。
  • disabled 计算属性只负责加 disabled 类名,按钮并没有真正禁用,所以 next 里必须再走一遍轻提示校验。
  • after-read 回调的 item 类型是“对象或数组”,必须先 Array.isArray(item) return,再判 item.file 是否存在,做完这两层类型守卫才能安全访问 item.file。
  • 上传三态由 item.status + item.message 控制:uploading / done / failed,别自己去定义状态变量。
  • 删除时要同时做两件事:组件内部已删掉 fileList 项,还必须按 item.url 过滤 form.pictures,否则表单里残留脏数据。
  • 上传成功时务必给 item.url 赋值,它是删除时定位图片的唯一标识。
  • 回显时 pictures 可能是 undefined,要兜底成 [];并且图片要回显到 fileList,而不是 form.pictures(上传组件绑的是 fileList)。
  • closeOnPopstate: false 必须加,否则路由回退时确认弹窗会被自动关掉,看不到回显提示。
  • max-size 单位是字节,5MB 要写成 5 * 1024 * 1024。

代码二:选择患者(复用家庭档案页面:界面兼容 + 选中效果 + 默认选中 + 记录 ID) ​

vue
<!-- src/views/user/PatientPage.vue -->
<template>
  <div class="patient-page">
    <cp-nav-bar :title="isChange ? '选择患者' : '家庭档案'" />

    <!-- 只有选择患者时才展示的提示 -->
    <div class="patient-tip" v-if="isChange">
      请选择需要就诊的患者,如未添加可点击下方按钮新增
    </div>

    <div class="patient-list">
      <div
        class="patient-item"
        v-for="item in list"
        :key="item.id"
        :class="{ selected: patientId === item.id }"
        @click="selectedPatient(item)"
      >
        <div class="info">
          <span class="name">{{ item.name }}</span>
          <span class="id">{{ item.idCard.replace(/^(.{6}).+(.{4})$/, '$1********$2') }}</span>
          <span>{{ item.genderValue }}</span>
          <span>{{ item.age }}岁</span>
        </div>
        <div class="tag" v-if="item.defaultFlag === 1">默认</div>
        <div class="edit" @click.stop="showPopup(item.id)">
          <van-icon name="edit" />
        </div>
      </div>

      <div class="patient-add" @click="showPopup()">
        <van-icon name="plus" />
        <p>添加患者</p>
      </div>
    </div>

    <!-- 只有选择患者时才展示的下一步 -->
    <div class="patient-next" v-if="isChange">
      <van-button type="primary" round block @click="next">下一步</van-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { useConsultStore } from '@/stores'
import { getPatientList } from '@/api/user'
import type { Patient } from '@/types/user'

const route = useRoute()
const router = useRouter()
const store = useConsultStore()

const list = ref<Patient[]>([])
const patientId = ref<string>()

// 地址栏带 isChange=1 才代表是“选择患者”场景
const isChange = computed(() => route.query.isChange === '1')

/* 点击患者:只有在选择患者场景下才记录 ID */
const selectedPatient = (item: Patient) => {
  if (isChange.value) patientId.value = item.id
}

/* 下一步:校验 + 记录到仓库 + 跳转支付页 */
const next = () => {
  if (!patientId.value) {
    showToast('请选择患者')
    return
  }
  store.setPatient(patientId.value)
  router.push('/consult/pay')
}

/* 加载列表:加载完成后做默认选中 */
const loadList = async () => {
  const res = await getPatientList()
  list.value = res.data

  if (isChange.value && list.value.length) {
    const defaultPatient = list.value.find((item) => item.defaultFlag === 1)
    if (defaultPatient) {
      patientId.value = defaultPatient.id
    } else {
      patientId.value = list.value[0].id
    }
  }
}

const showPopup = (id?: string) => {
  // 打开新增/编辑弹层的逻辑,略
}

onMounted(loadList)
</script>

<style lang="scss" scoped>
.patient-tip {
  padding: 12px;
  font-size: 12px;
  color: #999;
  background: #f5f6f7;
}
.patient-list {
  padding: 12px;
  .patient-item {
    position: relative;
    padding: 12px;
    border-radius: 8px;
    background: #fff;
    margin-bottom: 12px;
    border: 1px solid #eee;
    &.selected {
      border-color: #16c2a3;
      background: rgba(22, 194, 163, 0.06);
      &::after {
        content: '';
        position: absolute;
        right: 12px;
        bottom: 12px;
        width: 16px;
        height: 16px;
        background: #16c2a3;
        border-radius: 50%;
      }
    }
  }
  .patient-next {
    padding:

常见错误 ​

  1. 上传后 item.file 报“可能是数组/可能是 undefined” 现象:在 after-read 里写 item.file 直接被 TS 标红,运行到上传逻辑时也偶发报错。 原因:UploadAfterRead 的参数既可能是单个 UploadFileListItem,也可能是数组(多选);且 file 字段本身可选。 修法:先 if (Array.isArray(item)) return,再 if (!item.file) return,两重守卫之后再取 item.file,类型自然收窄。

  2. 选完图 fileList 里有了,但 form.pictures 始终是空数组 现象:上传成功、预览正常,但表单对象里没数据,提交时丢图。 原因:只做了 v-model="fileList" 收集本地预览,没有在 then 里把 res.data 回写到 form.pictures;也可能回写后没刷新视图。 修法:上传成功回调里 form.value.pictures.push(res.data);调试时看 Vue Devtools 里 pictures 长度是否与 fileList 一致。

  3. 删除图片后页面没了,pictures 里还留着 现象:点删除,列表少一张,但 form.pictures.length 不变,提交时多传图。 原因:on-delete 只处理了组件内部 fileList,未同步表单;且删除时 item 上没有唯一标识,无法定位。 修法:上传成功时顺手 item.url = res.data.url;删除时 form.value.pictures = form.value.pictures.filter(p => p.url !== item.url)。

  4. “下一步”按钮是灰的却还能点,且点了没反应 现象:disabled 样式生效,视觉上禁用,但点击照样触发跳转/无任何提示。 原因:只用 :class="{ disabled: isDisabled }" 加了样式类,没有 :disabled,按钮原生可点。 修法:要么加 :disabled="isDisabled" 真正禁用,要么保留可点并在 next() 里做轻提示校验(两者按产品要求二选一,别只改样式就以为拦住了)。

  5. 病情回显时图片不回显,或回显后 TS 报类型不匹配 现象:文本、单选项都恢复了,上传区仍空;或赋值那行直接红。 原因:上传组件绑定的是 fileList,与 form.pictures 是两套数据;且 fileList 未声明泛型,与 Image[] 不一致,pictures 还可能是 undefined。 修法:ref<UploadUserFile[]>([]) 声明泛型,回显时 fileList.value = form.pictures ?? [](或 store.consult.pictures ?? [])。

  6. 从下一页返回时,恢复提示弹窗不出现 现象:刷新有弹窗,路由前进再后退就没了,但 store 里数据明明还在。 原因:Dialog 默认 close-on-popstate 为 true,路由回退时自动关闭弹窗。 修法:showConfirmDialog({ closeOnPopstate: false, ... })。

  7. 点患者卡片上的“编辑”,患者被顺带选中了 现象:只想打开编辑面板,结果该患者变绿、ID 被记录。 原因:点击事件冒泡到外层 div 的 @click。 修法:编辑按钮的点击上加 .stop(@click.stop="...")。

  8. 默认选中失效,或默认选中了第一个而非“默认患者” 现象:进页面没有任何卡片高亮;或明明有 defaultFlag=1 的患者却选了列表第一项。 原因:默认选中逻辑写在了 onMounted,此时列表还没请求回来(length 为 0);或先取第一个再判断默认。 修法:放在列表请求完成之后,先 if (!isChange || !list.length) return,再 find(i => i.defaultFlag === 1),取不到才回退 list[0].id。

  9. 预支付/患者详情请求参数报类型错误 现象:consultOrderPay 里传 store.consult.patientId 提示 string | undefined 不能赋给 string。 原因:store 中字段类型本身可选,路由直达时确实可能没有值。 修法:调用前 if (!store.consult.patientId) return,收窄后再发请求;不要用 ! 强行断言掩盖空值场景。

练习 ​

  1. 给上传组件补上“最多 9 张、单张 5MB”的限制后,请分别构造一次超数量、超体积的上传,说明组件在哪一层拦截、fileList 与 pictures 是否会被污染,并写出验证步骤。

  2. 现在 next() 里对 consultFlag 用的是 === null 判断。若初始值改为 0(未就诊)或 '',该判断是否还成立?请写出三种初始值下的校验写法,并说明为什么不能用 !form.consultFlag。

  3. 不依赖 item.url,改用上传返回的 id 作为删除依据重写 onDeleteImage,并说明相比 url 方案的优势与需要注意的点。

  4. 把“恢复上次填写”从 onMounted 自动弹窗改为:仅当 store.consult.illnessDesc 存在且用户从其他页面返回时才提示。请写出判断条件,并说明如何避免首次进入重复弹窗。

  5. 用 Devtools 完整走一遍:填描述 → 选时间 → 选就诊 → 传 2 张图 → 下一步 → 回退 → 确认恢复。列出每一步应观察的 store 字段与组件状态,并指出最可能出问题的一步及排查方法。

课件图示 ​

课件第3页:极速问诊-病情描述-页面准备 路由组件结构 准备单选按钮 准备表单数据 绑定表单

课件第5页:极速问诊-病情描述-上传组件 结构与样式 配置图片文字 限制数量体积 收集图片数

课件第7页:极速问诊-病情描述-上传业务 上传图片API 上传&状态&同步 实现删除

课件第9页:极速问诊-病情描述-保存数据 按钮激活 轻提示校验 仓库记录病情 保存病情信息

课件第11页:极速问诊-病情描述-回显数据 有记录弹提示 回显表单 回显图片 回退显性弹窗

课件第15页:极速问诊-问诊支付-获取数据 路由与组件 预支付类型 预支付API 患者详情AP

原视频对照 ​

#视频时长
1极速问诊-病情描述-页面准备12:36
2极速问诊-病情描述-上传组件09:30
3极速问诊-病情描述-上传业务15:40
4极速问诊-病情描述-保存数据13:54
5极速问诊-病情描述-回显数据09:25
6极速问诊-选择患者17:28
7极速问诊-问诊支付-获取数据17:05

小结 ​

  1. 病情描述页的数据源要分清三套:form(业务表单)、fileList(上传组件内部状态)、store.consult(跨页面持久化)。三者不同步是本节绝大多数 bug 的根源。
  2. 上传的成功/失败/进行中三态由组件 item 上的 status + message 驱动,改动它们即可,不必自己造 loading 变量。
  3. 删除要能定位到唯一项,url 或 id 必须在上传成功那一刻就写回 item,否则删除时无从下手。
  4. 灰按钮只是视觉,真正的拦截必须在 next() 里做;校验顺序建议与页面字段顺序一致,便于用户定位。
  5. 类型守卫(Array.isArray / !== undefined)不是形式主义,它同时解决了 TS 报错与运行时 undefined 崩溃两类问题。
  6. 回显要覆盖全部字段:文本、单选、图片缺一不可;图片走 fileList 单独赋值,且注意泛型声明与 undefined 兜底。
  7. 家庭档案页被复用为“选择患者”,差异靠 route.query.isChange 计算属性驱动;新增交互时务必检查事件冒泡是否污染复用页面原有行为。
  8. 支付页只负责“取数据 + 渲染”,数据来源是两条独立请求(预支付、患者详情),挂载时并发发起,任一失败都应考虑降级展示。