主题
病情描述与问诊室布局(一)
本页对应 病情描述与问诊室布局,共 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:常见错误
上传后
item.file报“可能是数组/可能是 undefined” 现象:在after-read里写item.file直接被 TS 标红,运行到上传逻辑时也偶发报错。 原因:UploadAfterRead的参数既可能是单个UploadFileListItem,也可能是数组(多选);且file字段本身可选。 修法:先if (Array.isArray(item)) return,再if (!item.file) return,两重守卫之后再取item.file,类型自然收窄。选完图
fileList里有了,但form.pictures始终是空数组 现象:上传成功、预览正常,但表单对象里没数据,提交时丢图。 原因:只做了v-model="fileList"收集本地预览,没有在then里把res.data回写到form.pictures;也可能回写后没刷新视图。 修法:上传成功回调里form.value.pictures.push(res.data);调试时看 Vue Devtools 里pictures长度是否与fileList一致。删除图片后页面没了,
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)。“下一步”按钮是灰的却还能点,且点了没反应 现象:
disabled样式生效,视觉上禁用,但点击照样触发跳转/无任何提示。 原因:只用:class="{ disabled: isDisabled }"加了样式类,没有:disabled,按钮原生可点。 修法:要么加:disabled="isDisabled"真正禁用,要么保留可点并在next()里做轻提示校验(两者按产品要求二选一,别只改样式就以为拦住了)。病情回显时图片不回显,或回显后 TS 报类型不匹配 现象:文本、单选项都恢复了,上传区仍空;或赋值那行直接红。 原因:上传组件绑定的是
fileList,与form.pictures是两套数据;且fileList未声明泛型,与Image[]不一致,pictures还可能是undefined。 修法:ref<UploadUserFile[]>([])声明泛型,回显时fileList.value = form.pictures ?? [](或store.consult.pictures ?? [])。从下一页返回时,恢复提示弹窗不出现 现象:刷新有弹窗,路由前进再后退就没了,但 store 里数据明明还在。 原因:
Dialog默认close-on-popstate为true,路由回退时自动关闭弹窗。 修法:showConfirmDialog({ closeOnPopstate: false, ... })。点患者卡片上的“编辑”,患者被顺带选中了 现象:只想打开编辑面板,结果该患者变绿、ID 被记录。 原因:点击事件冒泡到外层
div的@click。 修法:编辑按钮的点击上加.stop(@click.stop="...")。默认选中失效,或默认选中了第一个而非“默认患者” 现象:进页面没有任何卡片高亮;或明明有
defaultFlag=1的患者却选了列表第一项。 原因:默认选中逻辑写在了onMounted,此时列表还没请求回来(length为 0);或先取第一个再判断默认。 修法:放在列表请求完成之后,先if (!isChange || !list.length) return,再find(i => i.defaultFlag === 1),取不到才回退list[0].id。预支付/患者详情请求参数报类型错误 现象:
consultOrderPay里传store.consult.patientId提示string | undefined不能赋给string。 原因:store 中字段类型本身可选,路由直达时确实可能没有值。 修法:调用前if (!store.consult.patientId) return,收窄后再发请求;不要用!强行断言掩盖空值场景。
练习
给上传组件补上“最多 9 张、单张 5MB”的限制后,请分别构造一次超数量、超体积的上传,说明组件在哪一层拦截、
fileList与pictures是否会被污染,并写出验证步骤。现在
next()里对consultFlag用的是=== null判断。若初始值改为0(未就诊)或'',该判断是否还成立?请写出三种初始值下的校验写法,并说明为什么不能用!form.consultFlag。不依赖
item.url,改用上传返回的id作为删除依据重写onDeleteImage,并说明相比url方案的优势与需要注意的点。把“恢复上次填写”从
onMounted自动弹窗改为:仅当store.consult.illnessDesc存在且用户从其他页面返回时才提示。请写出判断条件,并说明如何避免首次进入重复弹窗。用 Devtools 完整走一遍:填描述 → 选时间 → 选就诊 → 传 2 张图 → 下一步 → 回退 → 确认恢复。列出每一步应观察的 store 字段与组件状态,并指出最可能出问题的一步及排查方法。
课件图示






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