主题
订单详情与药品物流(一)
本页对应 订单详情与药品物流,共 6 段视频,约 1 小时 23 分。
学习目标
- 能使用
useClipboard在问诊详情中实现复制订单编号,并完成权限判断、失败轻提示和复制成功提示。 - 能抽取并封装支付抽屉组件,通过
props与update:show事件支持v-model:show,并在问诊支付、问诊详情页面复用。 - 能根据处方状态与订单生成情况实现“购买药品”跳转:无效处方提示、未支付且无订单进入药品支付、其他情况进入药品订单详情。
- 能定义药品预支付与收货地址类型,调用对应 API,在挂载时加载并渲染收货地址、手机号脱敏、药品清单、金额及加载骨架。
- 能实现药品支付流程:校验用户协议、收货地址和处方 ID,创建药品订单并缓存订单 ID,复用支付抽屉并支持动态回跳地址。
- 能实现药品支付结果页:根据订单 ID 获取订单详情,按待支付状态条件渲染成功/失败结果,并提供查看订单与返回问诊室跳转。
知识点
功能目标:把订单编号复制到剪贴板
为什么需要:用户找客服时经常要提供订单编号。订单编号通常很长,手动选中、复制容易漏字符,体验也差。因此在问诊详情页提供一个“复制”按钮,点击后直接把当前订单编号写入剪贴板,是成本低、收益明确的小功能。
怎么想:复制按钮不负责“展示订单编号”,只负责“读取当前订单数据里的编号并写入剪贴板”。所以处理函数里要拿到 order.value.orderNumber,调用浏览器剪贴板能力;同时要考虑浏览器是否允许复制,以及复制成功后给用户一个轻提示。
怎么做:本节采用 VueUse 提供的 useClipboard。转写稿里出现的“usec bad”“yels clip bed”“use clip back”都是同音/误写,实际是 useClipboard。它返回的关键内容有 copy 复制函数和 isSupported 是否支持复制。copy 的返回值是 Promise,因此复制成功后可以再提示“已复制”。
先确认 useClipboard 的用法和限制
为什么需要:不能一上来就调用复制 API。剪贴板 API 受安全上下文限制,转写中明确提到:只有在 HTTPS 网站下,或本地 localhost 开发环境下才能使用;HTTP 非 localhost 不支持。所以要先判断权限/支持情况。
怎么想:useClipboard 可以不传默认值,直接给 copy 传一个字符串;也可以传初始值。本节不需要默认值,只需要拿到 copy 和 isSupported。当 isSupported 为假时,终止复制并给用户轻提示;为真时再调用 copy。
怎么做:在组件中解构:
ts
const { copy, isSupported } = useClipboard()易错点:不要以为“本地 localhost 能复制,线上 HTTP 也一定能复制”。本地开发环境通常不检查权限,线上非 HTTPS 会失败;另外用户拒绝浏览器剪贴板授权后,也不能继续复制,所以要保留失败提示。
在组件中实现 onCopy 处理函数
为什么需要:复制逻辑要绑定到按钮点击事件上,并且要处理“不支持复制”“复制成功”“编号可能为空”等情况。
怎么想:准备一个 onCopy 函数,作为复制按钮的点击处理函数。函数内部先判断 isSupported.value,不支持就 showToast 提示并 return;支持则调用 copy,传入订单编号。转写中提到 orderNumber 可能是 undefined,而 copy 要求字符串,所以用默认空串或空值兜底,让类型检查通过。
怎么做:按转写还原关键代码:
ts
const onCopy = async () => {
if (!isSupported.value) {
showToast('未授权或不支持复制')
return
}
await copy(order.value.orderNumber ?? '')
showToast('已复制')
}模板中给复制按钮绑定点击事件:
html
<button @click="onCopy">复制</button>易错点:第一,不判断 isSupported 就直接复制;第二,copy 是 Promise,不 await 就提示“已复制”,可能复制失败也提示成功;第三,order.value.orderNumber 可能为空,未做兜底会导致类型检查不通过;第四,提示文案要区分“未授权/不支持”和“已复制”,不要混用。
调试与验证过程
为什么需要:复制功能不是页面上看得见的变化,必须实际粘贴验证。
怎么想:在本地 localhost 开发环境点击复制按钮,然后到控制台或输入框里粘贴,检查剪贴板中是否出现订单编号。转写中就是这样验证的:点击复制后,在控制台粘贴,能看到订单编号已经进入剪贴板。
怎么做:刷新项目,进入问诊详情,点击“复制”,再到控制台粘贴验证。若失败,依次检查:isSupported 是否为真、浏览器是否弹过权限提示、copy 是否抛错、传入的是否是字符串。最后提交代码,提交说明可为“问诊详情-复制订单编号”。
易错点:不要把“按钮点击了”当成“复制成功了”;也不要在本地验证通过后就忽略 HTTPS 环境差异。复制功能的核心不是按钮样式,而是权限判断、Promise 处理和用户提示三件事。
为什么把支付抽屉抽成组件
为什么需要:问诊支付页点击“立即支付”会弹出支付抽屉;问诊详情在待支付状态下点击“继续支付”,也要弹出同样的抽屉。如果两处各写一份模板和支付逻辑,后续改支付方式、改回跳地址都要改两遍,容易漏。
怎么想:先判断哪些是“公共的”:抽屉模板、支付方式选择、调用支付、生成支付地址、跳转第三方平台。哪些是“使用处不同的”:当前订单 ID、实付金额、抽屉显示隐藏、关闭前是否拦截。公共的留在组件内,不同的通过 props 传进去。
怎么做:在 components 下新建通用支付抽屉组件(转写稿中称 CpPay 组件),先从 consult-pay 页面把支付抽屉的模板结构拷过来,再把 pay 支付函数和 paymentMethods 支付方式数据拷过来。拷完后会出现标红,这些标红项就是组件缺少、需要由父组件传入的内容。
易错点:不要一上来就改逻辑,先搬模板再搬逻辑,最后看缺什么补什么;拷过来的 ref、轻提示、加载中提示、获取支付地址 API 等要重新导入;无用的导入要清理,否则类型检查和构建会报错。
先搬模板再搬逻辑
为什么需要:支付抽屉不是纯展示组件,它内部还要完成“选支付方式→立即支付→拿支付地址→跳转”的流程。只搬模板会导致点击无反应。
怎么想:模板里原来直接使用的 show、orderId、实付金额、onClose,在组件内部不再天然存在,要区分“组件内部状态”和“父组件传入数据”。支付方式 paymentMethods、支付函数 pay 属于组件内部逻辑,可以保留;订单 ID、金额、显示隐藏属于外部数据。
怎么做:把支付抽屉模板放进新组件,保留支付方式选择和支付按钮逻辑;把原来页面里的 pay 函数、paymentMethods 一并迁入;然后逐个处理标红项,记录需要暴露的 props。
易错点:不要把整个问诊支付页面都搬进抽屉组件;show 不是组件自己创建的 ref,而是父组件控制;orderId 不要继续写 orderId.value,因为 props 不是 ref。
用 props 补齐缺失数据
为什么需要:支付抽屉要知道“给哪个订单支付”“显示多少钱”“是否显示”“关闭前要不要拦截”,这些只有使用它的页面知道。
怎么想:按转写稿记录四类缺失:show 控制显示隐藏;orderId 订单 ID;actualPayment 实付金额;onClose 关闭前引导函数。其中 onClose 不是所有页面都需要:问诊支付要引导用户不要关闭,问诊详情不需要刻意拦截,所以设为可选。
怎么做:用 defineProps 约定类型,核心写法如下:
ts
const props = defineProps<{
show: boolean
orderId: string
actualPayment: number
onClose?: () => void
}>()模板中订单 ID 改为 props.orderId,实付金额改为 props.actualPayment。onClose 按项目原有 action-sheet 的关闭前钩子接入,未传时不拦截。
易错点:orderId 必须是字符串类型;actualPayment 是数字类型;onClose 要加问号表示可选;父组件传参时用 kebab-case,例如 :order-id、:actual-payment、:on-close。
用 v-model:show 打通显示隐藏
为什么需要:父页面要能打开抽屉,抽屉关闭时也要把“已关闭”同步回父页面。只传 show 只能单向控制,用户点关闭后父页面的 show 不会变。
怎么想:Vue 的 v-model:show 本质是 :show="show" 加 @update:show="show = $event"。子组件要接收 show,并在 action-sheet 触发 update:show 时向外抛同名事件。
怎么做:定义 emits:
ts
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()模板里 action-sheet 不要直接 v-model 绑定 props.show,因为 props 不能直接修改。应写成 :show="props.show",监听它的 update:show,再调用 emit('update:show', val) 通知父组件。这样组件就支持 v-model:show。
易错点:事件名必须是 update:show,不能写成 updateShow;emit 参数必须是布尔值;不要直接给 props.show 赋值;action-sheet 自身也有 update:show,要把它转成自己的 emit。
在两个页面复用与调试
为什么需要:组件封装完必须验证“问诊支付能用”和“问诊详情能用”,否则只是代码搬家。
怎么做:在问诊支付页使用支付抽屉组件,绑定 v-model:show="show"、:order-id、:actual-payment,并传入 :on-close 做关闭拦截。在问诊详情页的“继续支付”按钮上绑定点击事件,把 show 改为 true;页面下方使用组件,绑定当前问诊订单 ID 和实付金额,不传 on-close。
调试时先测问诊支付:点击立即支付能否打开抽屉、选择支付宝后能否跳转第三方;再测问诊详情:点击继续支付能否打开抽屉、样式是否正常。转写稿中第一次测试发现样式有问题,随后把支付抽屉样式拷到组件底部,并清理无用导入。
易错点:问诊详情不要传 on-close;show 默认应为 false;订单 ID 要取当前 item 的订单 ID,不要取错;样式要跟着组件走;改完组件后要回归问诊支付页,避免旧页面绑定失效。
需求判断:为什么不是所有“购买药品”都进支付页
为什么需要:处方消息卡片底部有“购买药品”按钮,但处方有状态,不能无脑跳支付页。过期处方不能支付;已支付或已经生成过订单的处方,也不应重复创建药品订单。
怎么想:先判断处方是否有效,再判断“未支付且没有订单ID”,只有这个组合才进入药品支付;其他情况进入药品订单详情。
怎么做:在处方消息卡片里给按钮绑定点击事件,把当前处方对象传给处理函数。处理函数里先排除空处方,再按状态分支跳转。
易错点:处方参数可能是 undefined,不能直接读 p.status;无效处方要提示“处方已失效”后终止;未支付但已有 orderId 时也应去详情,而不是再次支付。
(本节笔记截图位置:处方消息卡片底部“购买药品”按钮。)
点击事件与类型处理:先把可选处方收窄
为什么需要:模板里的处方数据来自消息对象,类型上可能是可选值。若不先处理空值,后续判断和路由跳转都会报类型错误或运行时报错。
怎么想:函数参数写成可选,进入函数第一件事就是 if (!p) return,把类型从“可能没有”收窄为“一定有”。
怎么做:
ts
// 处方消息卡片:点击“购买药品”
const router = useRouter()
const goPay = (p?: Prescription) => {
if (!p) return
if (p.status === PrescriptionStatus.Invalid) {
showToast('处方已失效')
return
}
if (p.status === PrescriptionStatus.Unpaid && !p.orderId) {
router.push({ path: '/order-pay', query: { id: p.id } })
return
}
router.push({ path: '/order-detail', query: { orderId: p.orderId } })
}易错点:PrescriptionStatus 要从项目枚举中导入;未支付状态、无效状态不要手写字符串;showToast 是轻提示,不要写成 alert;跳支付页带的是处方ID,跳详情带的是订单ID,二者不能混。
分支跳转:三条路径对应三种业务结果
为什么需要:同一个按钮在不同处方状态下结果不同,分支必须清楚,否则会出现“已支付还去支付”“过期处方还能下单”等问题。
怎么想:按业务优先级排列:空处方直接返回;无效处方提示;未支付且无订单去支付;剩余情况去订单详情。
怎么做:用 router.push 跳转。药品支付页用 query.id 传处方ID,后续页面从地址栏取这个ID拉预支付信息;订单详情用 query.orderId 传订单ID。
易错点:转写稿里“路径上拼接”容易听成路径参数,但后续页面实际通过 route.query.id、route.query.orderId 取参,所以这里统一用 query;不要漏掉 return,否则会继续执行后面的详情跳转。
配置药品支付路由与页面骨架
为什么需要:点击后要落到药品支付页,如果没有路由和组件,页面会白屏或 404。
怎么想:在 order 模块下新建 order-pay.vue,在路由表里增加 /order-pay,标题设为“药品支付”;页面结构先复制笔记中的基础布局,数据渲染留到下一节。
怎么做:
ts
{
path: '/order-pay',
component: () => import('@/views/order/order-pay.vue'),
meta: { title: '药品支付' }
}易错点:组件路径要放在 order 文件夹下;路由地址不要写成问诊支付;标题别复制成旧标题;页面骨架先搭好,不要在本节提前写预支付接口逻辑。
调试过程与易错清单
为什么需要:这类跳转逻辑必须用真实处方状态验证,否则只测一种状态很容易漏分支。
怎么想:先在问诊室找到处方卡片,点击“购买药品”,观察地址栏是否进入 /order-pay?id=处方ID;再检查页面是否显示基础布局。
怎么做:浏览器刷新后点击按钮,正常未支付且无订单的处方应进入药品支付页;若白屏,先查路由是否注册、组件路径是否正确;若提示失效,查处方状态枚举;若直接进详情,查 orderId 是否已有值。
易错点:本地开发环境能正常跳转不代表线上权限问题;本节只做“进入药品支付”的跳转与骨架,预支付信息、收货地址、支付抽屉是后续内容,不要提前混入。
代码示例
1. 问诊详情 —— 复制订单编号(useClipboard)
vue
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { showToast } from 'vant'
const props = defineProps<{ orderNumber?: string }>()
// useClipboard 返回 copy 函数和 isSupported(当前环境是否允许使用剪贴板)
const { copy, isSupported } = useClipboard()
// 点击「复制」按钮时触发
const onCopy = async () => {
// 1. 先判断浏览器/协议是否支持剪贴板 API
if (!isSupported.value) {
showToast('未授权,不支持复制')
return
}
// 2. copy 必须传字符串,返回值是 Promise,成功后再提示
await copy(props.orderNumber ?? '')
showToast('已复制')
}
</script>
<template>
<div class="order-no">
<span>{{ props.orderNumber }}</span>
<van-button size="mini" plain type="primary" @click="onCopy">复制</van-button>
</div>
</template>关键行与易错点
isSupported必须先判断:只有 HTTPS 协议或localhost本地开发环境才允许调用剪贴板 API,普通 HTTP 下会失败,此时给用户轻提示而不是静默报错。copy(xxx)的参数必须是字符串,订单编号字段可能是undefined,所以用?? ''兜底,否则类型检查和运行都会出问题。copy返回的是 Promise,处理函数要写成async,await之后再showToast('已复制'),这样提示和复制结果才对应得上。- 本地
localhost下浏览器不会弹授权框,容易误以为「权限判断没用」,要理解这是环境差异而不是代码问题。
2. 支付抽屉组件封装(props + v-model:show + 事件)
vue
<script setup lang="ts">
import { ref } from 'vue'
import { showLoadingToast, showToast } from 'vant'
import { getPayUrl } from '@/services/consult'
// 从原问诊支付页面搬过来的逻辑所需的数据与函数
const props = defineProps<{
show: boolean // 控制抽屉显隐
orderId: string // 订单 ID
actualPayment: number // 实付金额
onClose?: () => void // 关闭前的引导函数,可不传
}>()
// 支持 v-model:show 双向绑定
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
const payment = ref(1)
// 支付方式列表沿用原问诊支付页面的数据
const paymentMethods = ref<{ value: number; label: string }[]>([])
// 点击「立即支付」:根据订单 ID 拿支付地址并跳转第三方
const pay = async () => {
const loading = showLoadingToast({ message: '加载中...', forbidClick: true, duration: 0 })
try {
const { data } = await getPayUrl(props.orderId)
loading.close()
window.location.href = data.url
} catch (e) {
loading.close()
showToast('支付失败')
}
}
</script>
<template>
<van-action-sheet
:show="props.show"
@update:show="emit('update:show', $event)"
:before-close="props.onClose"
>
<div class="pay-sheet">
<h3>选择支付方式</h3>
<van-radio-group v-model="payment">
<van-cell-group>
<van-cell
v-for="item in paymentMethods"
:key="item.value"
:title="item.label"
clickable
@click="payment = item.value"
>
<template #right-icon>
<van-radio :name="item.value" />
</template>
</van-cell>
</van-cell-group>
</van-radio-group>
<div class="pay-amount">
实付款:<span>¥{{ props.actualPayment }}</span>
</div>
<van-button type="primary" block @click="pay">立即支付</van-button>
</div>
</van-action-sheet>
</template>关键行与易错点
- 抽屉内缺少的东西(
show、orderId、actualPayment、onClose)全部通过defineProps暴露;onClose加?因为问诊详情页不需要拦截关闭,只有问诊支付页才传。 - 模板里不能对
props.show用v-model:props 是只读的,必须写成:show="props.show"+@update:show="emit('update:show', $event)",配合defineEmits声明update:show,才能让父组件用v-model:show。 - 事件名必须是
update:show(冒号不能写错),这是v-model:show的约定;$event就是 action-sheet 抛出的新布尔值。 - 关闭前引导用
before-close绑定props.onClose,未传时值为undefined,van 会按「允许关闭」处理,不会报错。 orderId不能用orderId.value,因为它已经是 props 而不是 ref。- 改造后记得跑一遍类型检查(
pnpm type-check),能快速定位到consult pay、consult detail两个旧使用处缺少必传属性。
3. 药品订单 —— 点击「购买药品」的路由判断
ts
// 处方消息卡片中的购买药品按钮逻辑
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import type { Prescription } from '@/types/consult'
import { PrescriptionStatus } from '@/enums'
const router = useRouter()
const buy = (prescription?: Prescription) => {
// 1. 类型守卫:处方信息可能是 undefined,直接返回
if (!prescription) return
// 2. 无效处方:提示后终止
if (prescription.status === PrescriptionStatus.Invalid) {
showToast('处方已失效')
return
}
// 3. 未支付 且 从未根据该处方生成过订单 → 去药品支付页
if (
prescription.status === PrescriptionStatus.NotPayment &&
!prescription.orderId
) {
router.push(`/order/pay?id=${prescription.id}`)
return
}
// 4. 其他情况一律去药品订单详情
router.push(`/order/${prescription.orderId}`)
}vue
<!-- 处方卡片底部按钮 -->
<template>
<van-button type="primary" size="small" @click="buy(item.msg.prescription)">
购买药品
</van-button>
</template>关键行与易错点
- 函数参数要声明为可选(
prescription?: Prescription),因为卡片数据里处方字段可能是undefined;先做if (!prescription) return的类型收窄,后面才能安全访问status、orderId。 - 判断顺序不能乱:先失效 → 再未支付且无订单 → 其余走详情,否则会把失效处方也跳去支付页。
- 「未支付」和「没生成过订单」是两个条件同时满足才去支付页,
orderId有值说明订单已生成,应直接去详情,避免重复下单。 - 路由跳转要用
useRouter()拿到 router 实例,push的路径按约定拼处方 ID / 订单 ID,别漏掉id参数,否则支付页拿不到route.query.id就无法拉预支付信息。 - 轻提示统一用
showToast,无效处方提示后必须return,不然会继续往下执行。
常见错误
复制按钮点了没反应,或控制台报「copy 参数必须是 string」 现象:
onCopy里调用copy(order.value.orderNumber),TS 报红、运行时偶尔复制出"undefined"。 原因:订单号在类型上是可选的(string | undefined),useClipboard的copy只接受字符串,且未做空值兜底。 修法:copy(order.value.orderNumber ?? ''),并在调用前判断if (!supported) return showToast('当前环境不支持复制');copy返回 Promise,成功后再showToast('已复制')。支付抽屉里仍写
v-model="show",抽屉关不掉 / 控制台警告「Set operation on key failed」 现象:点击遮罩或取消,action-sheet不消失,或报 props 只读警告。 原因:show已从组件内部ref变成defineProps的只读属性,v-model会尝试回写 props。 修法:模板改成:show="props.show",并监听@update:show="val => emit('update:show', val)";父组件才用v-model:show。父组件
v-model:show失效,抽屉能开不能关 现象:父页面show = true能打开,但抽屉内部关闭后父页面show仍是true,再次点击「继续支付」无反应。 原因:只声明了show这个 prop,漏了defineEmits里的update:show,子组件状态变化没有向上通知。 修法:const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>(),在action-sheet的update:show回调里转发出去。给
pay-callback加必填 prop 后,其他页面运行时报错或跳到/undefined现象:药品支付正常,但从问诊支付/问诊详情进入时支付成功跳到了错误路由。 原因:pay-callback设为必填,历史调用点没传,编译期没拦截住(或直接跳过了检查)。 修法:改完公共组件立刻跑pnpm type-check,按提示给consult-pay、consult-detail补pay-callback="/room";如确实可缺省,就设为可选并给默认值。支付结果页同时显示骨架屏和真实内容,或条件渲染永远走「支付成功」分支 现象:骨架屏一直挂在页面上;或无论是否支付成功都显示成功界面。 原因:一是漏写
v-if / v-else的配对;二是order.status的类型没从OrderType枚举里取,退化成any/字符串比较失败。 修法:骨架屏加v-if="!order"、内容加v-else;状态判断用枚举OrderType.MedicinePay而非硬编码字符串,并确认枚举已import。连点「立即支付」生成多笔药品订单 现象:网络稍慢时快速点两次,后台多出两条订单。 原因:
onSubmit里没有「已有 orderId 就不再请求」的短路,也没用loading锁住按钮。 修法:入口先if (orderId.value) { show.value = true; return },请求前置loading = true,try/finally中复位,按钮绑:loading。
练习
- 给复制功能加降级方案:当
useClipboard的isSupported为false(如 HTTP 环境)时,改用document.execCommand('copy')或提示用户手动长按复制,保证功能可用。 - 为支付抽屉增加一个可选 prop
title(默认「选择支付方式」),并同步在consult-pay、consult-detail、order-pay三处调用点验证不传时行为不变。 - 把「处方有效性 → 是否已生成订单 → 跳转目标」的判断抽成一个纯函数
resolvePrescriptionTarget(prescription),返回路由对象,并为其写 3 条单元测试(失效 / 未支付无订单 / 其他)。 - 在药品支付页给「立即支付」加防抖或 loading 锁,构造慢网络场景验证:连续点击 5 次只产生 1 个订单号。
- 改造支付结果页,增加「支付中」的第三种状态展示(订单状态仍为待支付但刚回跳、且 3 秒内允许轮询一次详情),并说明何时停止轮询。
课件图示






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 问诊详情-复制订单编号 | 06:48 |
| 2 | 问诊详情-支付抽屉组件 | 17:16 |
| 3 | 药品订单-进入药品支付 | 09:03 |
| 4 | 药品订单-支付信息展示 | 17:55 |
| 5 | 药品支付-进行支付 | 18:31 |
| 6 | 药品订单-支付结果 | 13:52 |
小结
- 可复用组件的边界不在模板,而在 props / emits:凡是「组件内需要但自己造不出来」的数据一律 props 进来,「组件内改了要外面知道」的一律 emit 出去,模板搬运只是第一步。
- 双向绑定不是语法糖问题,而是契约问题:
v-model:show必须由:show+update:show成对实现,缺一半就会出现「能开不能关」这类半瘫状态。 - 公共组件的每一次签名变更都要用类型检查兜底。
pnpm type-check在这里的价值不是找错,而是列出所有需要一起改的调用点,把重构风险从运行时前移到编译时。 - 状态判断一律用枚举,不要用字面量字符串。订单状态这类跨端共享的语义,一旦写死字符串,后台加一个状态就静默走错分支。
- 页面级数据展示的统一套路是「先有类型 → 再有 API → 挂载取数 → 有数据才渲染」,骨架屏与内容用
v-if / v-else严格互斥,避免出现空数据下的白屏或双份渲染。 - 支付这类会产生副作用的操作,幂等性要在前端先兜一层:已有
orderId就不再建单,只重开抽屉,把重复提交挡在请求之前。 - 判断「支付是否成功」不要看回跳本身,要看服务端订单状态——回跳只代表用户从第三方平台回来了,不代表钱到了。