医保结算并不是"点击按钮、调用接口、显示成功"这么简单。它同时涉及患者身份认证、医保设备通信、统筹与个人账户分流、本地与异地政策差异、预结算确认、现金补差、凭证打印等多个环节。
如果把所有逻辑都堆在一个点击事件里,代码很快就会被大量 if/else、弹窗状态和异步回调淹没。本文结合一个门诊收费页面的实现,讨论如何在 Vue 2 项目中组织这类多分支、强交互的结算流程,以及其中值得复用的工程经验。
一、先把结算问题拆成三个维度
医保流程看似分支很多,实际上可以拆成三个相互独立的选择维度。
1. 参保区域
- 本地参保
- 异地参保
异地结算通常需要额外选择参保地,并在请求中携带参保地标识。某些地区或结算方式可能存在政策限制,因此应在进入设备交互前完成校验,避免患者完成身份认证后才被告知无法结算。
2. 结算模式
- 普通门诊统筹
- 门诊慢特病统筹
- 本地个人账户
- 异地个人账户
建议不要在业务代码里散落难以理解的数字常量,而是使用语义化枚举:
js
const SETTLEMENT_MODE = Object.freeze({
OUTPATIENT_POOLING: "outpatient_pooling",
CHRONIC_POOLING: "chronic_pooling",
LOCAL_ACCOUNT: "local_account",
REMOTE_ACCOUNT: "remote_account",
});
即使后端仍要求传递数字编码,也应只在请求适配层进行转换。
3. 身份认证介质
- 社会保障卡
- 医保电子凭证
- 居民身份证
- 人脸认证
不同介质的采集方式不同,但成功后都应收敛为统一的参保人上下文,例如:
js
function normalizeInsuredPerson(raw) {
return {
personId: raw.personId,
personName: raw.personName,
cardToken: raw.cardToken || "",
credentialToken: raw.credentialToken || "",
agencyCode: raw.agencyCode || "",
};
}
后续预结算逻辑只依赖标准化数据,不需要反复判断身份来自实体卡、二维码还是人脸设备。
二、浏览器、业务后端与本地设备的协作
医保结算通常同时存在两类通信:
- 浏览器通过 HTTP 请求业务后端,完成参保信息查询、预结算、正式结算与凭证获取。
- 浏览器通过本地 WebSocket 中间件驱动读卡器、扫码器、密码键盘或人脸终端。
整体关系可以抽象为:
#mermaid-svg-c8K2OBd48hvjpdeA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c8K2OBd48hvjpdeA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c8K2OBd48hvjpdeA .error-icon{fill:#552222;}#mermaid-svg-c8K2OBd48hvjpdeA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c8K2OBd48hvjpdeA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c8K2OBd48hvjpdeA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c8K2OBd48hvjpdeA .marker.cross{stroke:#333333;}#mermaid-svg-c8K2OBd48hvjpdeA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c8K2OBd48hvjpdeA p{margin:0;}#mermaid-svg-c8K2OBd48hvjpdeA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-c8K2OBd48hvjpdeA .cluster-label text{fill:#333;}#mermaid-svg-c8K2OBd48hvjpdeA .cluster-label span{color:#333;}#mermaid-svg-c8K2OBd48hvjpdeA .cluster-label span p{background-color:transparent;}#mermaid-svg-c8K2OBd48hvjpdeA .label text,#mermaid-svg-c8K2OBd48hvjpdeA span{fill:#333;color:#333;}#mermaid-svg-c8K2OBd48hvjpdeA .node rect,#mermaid-svg-c8K2OBd48hvjpdeA .node circle,#mermaid-svg-c8K2OBd48hvjpdeA .node ellipse,#mermaid-svg-c8K2OBd48hvjpdeA .node polygon,#mermaid-svg-c8K2OBd48hvjpdeA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-c8K2OBd48hvjpdeA .rough-node .label text,#mermaid-svg-c8K2OBd48hvjpdeA .node .label text,#mermaid-svg-c8K2OBd48hvjpdeA .image-shape .label,#mermaid-svg-c8K2OBd48hvjpdeA .icon-shape .label{text-anchor:middle;}#mermaid-svg-c8K2OBd48hvjpdeA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-c8K2OBd48hvjpdeA .rough-node .label,#mermaid-svg-c8K2OBd48hvjpdeA .node .label,#mermaid-svg-c8K2OBd48hvjpdeA .image-shape .label,#mermaid-svg-c8K2OBd48hvjpdeA .icon-shape .label{text-align:center;}#mermaid-svg-c8K2OBd48hvjpdeA .node.clickable{cursor:pointer;}#mermaid-svg-c8K2OBd48hvjpdeA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-c8K2OBd48hvjpdeA .arrowheadPath{fill:#333333;}#mermaid-svg-c8K2OBd48hvjpdeA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-c8K2OBd48hvjpdeA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-c8K2OBd48hvjpdeA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c8K2OBd48hvjpdeA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-c8K2OBd48hvjpdeA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c8K2OBd48hvjpdeA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-c8K2OBd48hvjpdeA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-c8K2OBd48hvjpdeA .cluster text{fill:#333;}#mermaid-svg-c8K2OBd48hvjpdeA .cluster span{color:#333;}#mermaid-svg-c8K2OBd48hvjpdeA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-c8K2OBd48hvjpdeA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-c8K2OBd48hvjpdeA rect.text{fill:none;stroke-width:0;}#mermaid-svg-c8K2OBd48hvjpdeA .icon-shape,#mermaid-svg-c8K2OBd48hvjpdeA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c8K2OBd48hvjpdeA .icon-shape p,#mermaid-svg-c8K2OBd48hvjpdeA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-c8K2OBd48hvjpdeA .icon-shape .label rect,#mermaid-svg-c8K2OBd48hvjpdeA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c8K2OBd48hvjpdeA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-c8K2OBd48hvjpdeA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-c8K2OBd48hvjpdeA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HTTPS
本地 WebSocket
设备事件
Vue 2 收费页面
业务后端
医保服务平台
医保设备中间件
读卡/扫码/密码/人脸设备
设备通信是事件驱动的:前端发送"读取某类介质"的请求,中间件稍后推送成功、失败或取消事件。因此不能把 WebSocket 当作普通同步函数使用,而应建立清晰的状态:
js
data() {
return {
deviceConnected: false,
settlementStage: "idle",
statusMessage: "",
insuredPerson: null,
preSettlement: null,
settlementTransactionId: "",
};
}
推荐至少区分以下阶段:
text
idle
→ choosing_credential
→ reading_credential
→ loading_person
→ pre_settling
→ reviewing
→ waiting_confirmation
→ settling
→ supplementing_payment
→ completed / failed / cancelled
页面可以继续用多个 Element UI 弹窗承载交互,但流程判断最好依赖 settlementStage,而不是仅依赖"某个弹窗当前是否可见"。
三、用统一入口完成身份认证后的分流
实体卡、电子凭证、身份证和人脸认证的设备事件格式并不一致。如果每个事件处理函数都直接继续结算,后续会形成四套高度重复的流程。
更好的方式是:每种介质只负责采集和标准化身份,成功后统一进入分流方法。
js
async function continueAfterIdentity() {
this.settlementStage = "loading_person";
if (this.credentialType !== "social_security_card") {
await this.loadInsuredPerson();
}
switch (this.settlementMode) {
case SETTLEMENT_MODE.OUTPATIENT_POOLING:
case SETTLEMENT_MODE.CHRONIC_POOLING:
return this.startPoolingPreSettlement();
case SETTLEMENT_MODE.LOCAL_ACCOUNT:
return this.requestAccountPassword();
case SETTLEMENT_MODE.REMOTE_ACCOUNT:
return this.startRemoteAccountSettlement();
default:
throw new Error("未选择有效的医保结算方式");
}
}
这种设计的价值在于:身份介质负责"我是谁",结算模式负责"接下来怎么付",二者不互相污染。
四、门诊统筹结算主流程
统筹结算的核心不是一次结算请求,而是"预结算---人工确认---设备确认---正式结算"的闭环。
#mermaid-svg-auDLJltDUEazOsyy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-auDLJltDUEazOsyy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-auDLJltDUEazOsyy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-auDLJltDUEazOsyy .error-icon{fill:#552222;}#mermaid-svg-auDLJltDUEazOsyy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-auDLJltDUEazOsyy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-auDLJltDUEazOsyy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-auDLJltDUEazOsyy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-auDLJltDUEazOsyy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-auDLJltDUEazOsyy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-auDLJltDUEazOsyy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-auDLJltDUEazOsyy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-auDLJltDUEazOsyy .marker.cross{stroke:#333333;}#mermaid-svg-auDLJltDUEazOsyy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-auDLJltDUEazOsyy p{margin:0;}#mermaid-svg-auDLJltDUEazOsyy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-auDLJltDUEazOsyy .cluster-label text{fill:#333;}#mermaid-svg-auDLJltDUEazOsyy .cluster-label span{color:#333;}#mermaid-svg-auDLJltDUEazOsyy .cluster-label span p{background-color:transparent;}#mermaid-svg-auDLJltDUEazOsyy .label text,#mermaid-svg-auDLJltDUEazOsyy span{fill:#333;color:#333;}#mermaid-svg-auDLJltDUEazOsyy .node rect,#mermaid-svg-auDLJltDUEazOsyy .node circle,#mermaid-svg-auDLJltDUEazOsyy .node ellipse,#mermaid-svg-auDLJltDUEazOsyy .node polygon,#mermaid-svg-auDLJltDUEazOsyy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-auDLJltDUEazOsyy .rough-node .label text,#mermaid-svg-auDLJltDUEazOsyy .node .label text,#mermaid-svg-auDLJltDUEazOsyy .image-shape .label,#mermaid-svg-auDLJltDUEazOsyy .icon-shape .label{text-anchor:middle;}#mermaid-svg-auDLJltDUEazOsyy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-auDLJltDUEazOsyy .rough-node .label,#mermaid-svg-auDLJltDUEazOsyy .node .label,#mermaid-svg-auDLJltDUEazOsyy .image-shape .label,#mermaid-svg-auDLJltDUEazOsyy .icon-shape .label{text-align:center;}#mermaid-svg-auDLJltDUEazOsyy .node.clickable{cursor:pointer;}#mermaid-svg-auDLJltDUEazOsyy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-auDLJltDUEazOsyy .arrowheadPath{fill:#333333;}#mermaid-svg-auDLJltDUEazOsyy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-auDLJltDUEazOsyy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-auDLJltDUEazOsyy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-auDLJltDUEazOsyy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-auDLJltDUEazOsyy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-auDLJltDUEazOsyy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-auDLJltDUEazOsyy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-auDLJltDUEazOsyy .cluster text{fill:#333;}#mermaid-svg-auDLJltDUEazOsyy .cluster span{color:#333;}#mermaid-svg-auDLJltDUEazOsyy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-auDLJltDUEazOsyy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-auDLJltDUEazOsyy rect.text{fill:none;stroke-width:0;}#mermaid-svg-auDLJltDUEazOsyy .icon-shape,#mermaid-svg-auDLJltDUEazOsyy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-auDLJltDUEazOsyy .icon-shape p,#mermaid-svg-auDLJltDUEazOsyy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-auDLJltDUEazOsyy .icon-shape .label rect,#mermaid-svg-auDLJltDUEazOsyy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-auDLJltDUEazOsyy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-auDLJltDUEazOsyy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-auDLJltDUEazOsyy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 放弃
确认
否
是
选择统筹类型
选择身份介质
设备完成身份认证
补全参保人信息
发起统筹预结算
展示预结算明细
取消本次流程
医保终端展示金额
确认是否使用个人账户
提交正式统筹结算
是否仍有待支付金额
完成并打印凭证
进入个账或现金补差
预结算请求通常需要以下业务信息:
- 认证方式与身份令牌
- 就诊标识和处方标识集合
- 是否包含挂号费
- 本地/异地标识与参保地
- 当前收费员等审计信息
预结算返回后,应把统筹支付、个人负担、账户余额、起付线和其他支付项集中展示,让收费员和患者确认,而不是立即提交最终结算。
正式结算成功后,要以服务端返回的待支付金额为准更新页面。若异地统筹后仍有个人负担,可再进入异地个人账户身份认证与支付;若个人账户不足,则继续走现金、扫码或院内储值等补差渠道。
五、本地与异地个人账户流程
本地个人账户
本地个账通常遵循:
text
身份认证
→ 获取支付密码
→ 个账预结算
→ 展示预结算单
→ 患者确认
→ 个账正式结算
→ 处理剩余自费金额
这里要特别注意,密码属于高敏感瞬时数据。前端不应把它写入本地存储、URL、埋点或普通日志;完成请求后应尽快清空内存中的引用。
异地个人账户
异地个账通常多出"异地身份认证"和"参保地校验":
text
校验参保地与政策限制
→ 异地身份认证
→ 获取账户余额
→ 获取支付密码
→ 确认个账支付金额
→ 提交异地个账支付
→ 使用其他方式补足剩余金额
对于"统筹结算后的异地个账补充支付",建议重新做一次有效身份认证。较早获取的二维码或身份令牌可能已经过期,不能默认复用。
六、金额处理:前端校验不能破坏服务端预结算结果
医保金额计算有两个基本原则。
第一,正式金额以服务端预结算和结算结果为准;前端计算主要服务于交互展示与输入校验。
第二,金额不要直接依赖 JavaScript 浮点运算。项目中可以使用 Decimal 类库,或统一转成"分"进行整数计算:
js
function yuanToCent(value) {
return Math.round((Number(value) || 0) * 100);
}
function centToYuan(value) {
return (value / 100).toFixed(2);
}
一个很典型的 Vue 2 陷阱,是使用 watcher 监听预结算金额:
js
watch: {
"preSettlement.accountPayment"(value) {
this.validateAccountPayment(value);
},
}
当接口把整个预结算对象赋值给组件时,这个 watcher 也会触发。前端可能把服务端返回值误判为"用户输入超限",不仅弹出错误提示,还会反向修改接口数据。
更稳妥的做法是只在用户输入事件中校验:
vue
<el-input
v-model="preSettlement.accountPayment"
@input="handleAccountPaymentInput"
/>
js
function handleAccountPaymentInput(value) {
const payment = yuanToCent(value);
const available =
yuanToCent(this.preSettlement.cardBalance) +
yuanToCent(this.preSettlement.sharedBalance);
if (payment > available) {
this.$message.error("账户支付金额不能超过可用余额");
return;
}
this.recalculateDisplayAmount(payment);
}
这样,接口初始化与用户编辑被明确区分,预结算数据不会被响应式副作用篡改。最终提交前仍需再执行一次完整校验,后端也必须进行权威校验。
七、设备事件处理要防重复、防串单
WebSocket 回调可能迟到、重复,甚至在用户切换患者后才返回。生产实现中建议为每次收费创建前端流程 ID,并校验所有回调是否仍属于当前流程:
js
startSettlement() {
this.flowId = createUniqueFlowId();
this.settlementStage = "choosing_credential";
}
handleDeviceMessage(message) {
if (message.flowId !== this.flowId) return;
if (!this.isExpectedEvent(message.eventType)) return;
this.dispatchDeviceEvent(message);
}
此外还应做到:
- 确认按钮提交期间禁用,防止重复结算。
- 每次正式结算携带幂等业务号。
- 用户取消时通知设备中间件终止当前操作。
- 连接断开后给出明确状态,不要自动重复提交结算请求。
- 页面销毁时移除键盘监听、关闭连接或取消订阅。
- 快捷键只能在目标弹窗可见且流程阶段正确时生效,并再次展示脱敏后的患者确认信息。
八、错误处理不要只有一个 Toast
医保流程持续时间长,且很多步骤需要患者配合。只弹出一个短暂 Toast 往往无法说明当前状态。
建议同时提供三层反馈:
- 流程状态弹窗:提示"等待刷卡""等待输入密码""正在预结算"等持续状态。
- 即时消息:提示某一步成功、失败或被取消。
- 可恢复操作:区分"重新读取身份""重新预结算""继续现金补差"和"放弃本次结算"。
错误对象也应带上所处阶段:
js
function handleSettlementError(error, stage) {
this.settlementStage = "failed";
this.statusMessage = getSafeUserMessage(error, stage);
reportSafeError({
stage,
errorCode: error.code,
flowId: this.flowId,
});
}
日志只记录流程 ID、阶段和错误码,不记录姓名、证件号、卡号、二维码令牌、密码、完整请求报文或结算凭证地址。
九、医保前端必须落实的数据安全边界
技术博客中的"脱敏"不能只停留在把姓名替换成星号。真实系统至少应做到:
- 页面只展示业务必需的身份信息,并默认掩码。
- 身份令牌、卡识别码、密码和人脸授权信息只保存在内存中。
- 禁止在
console.log、监控埋点和错误上报中输出完整设备消息或接口响应。 - 不在前端源码中硬编码机构编码、科室、操作员、内网地址和设备端口。
- 浏览器到后端使用 HTTPS;设备通信由受控的本地代理完成,并校验消息来源。
- 后端负责签名、鉴权、幂等、金额复核和审计,前端不能成为信任边界。
- 打印地址使用短期授权链接,打印结束后及时失效。
- 切换患者、取消结算或页面销毁时清空医保上下文。
可以封装统一的掩码函数,避免每个页面自行处理:
js
function maskCredential(value = "") {
if (value.length < 8) return "****";
return `${value.slice(0, 3)}********${value.slice(-3)}`;
}
十、从单文件组件继续演进
当一个收费页面同时承载患者列表、处方、收费、医保设备、退费和打印时,单文件组件很容易增长到数千行。后续可以按职责拆分:
text
views/toll/
├── TollPage.vue
├── components/
│ ├── SettlementModePanel.vue
│ ├── CredentialSelector.vue
│ ├── PoolingPreviewDialog.vue
│ ├── AccountPreviewDialog.vue
│ └── SettlementStatusDialog.vue
├── services/
│ ├── insuranceApi.js
│ └── insuranceDevice.js
└── composables/
└── settlementMachine.js
Vue 2 项目可以使用 Vuex 模块、普通 service 类或可复用 mixin 承载流程;如果允许引入状态机库,也可以将所有合法状态迁移显式建模。关键不是采用哪种工具,而是把"界面显示""设备协议""业务流程""接口适配"四层分开。
结语
医保结算前端最难的部分,从来不是某一个接口,而是跨设备、跨区域、跨支付方式的长事务协作。
一套可靠的实现通常具备这些特征:身份认证入口统一、结算分支清晰、预结算与正式结算严格分离、金额由服务端定稿、设备事件可追踪且可取消、失败后能够恢复,同时敏感数据不进入日志和持久化存储。
当我们把流程从零散的弹窗和回调提升为明确的状态模型后,代码会更容易测试,收费人员也更清楚系统正在等待什么。对于医疗收费这类高可靠业务,这种可解释性本身就是系统质量的一部分。