feat: 修复部分功能(健康信息、VIP、医生端健康信息导入)

This commit is contained in:
李琦
2026-08-08 10:33:41 +08:00
parent a7dd202ff5
commit 26cdfd718f
13 changed files with 776 additions and 26 deletions

View File

@@ -0,0 +1,49 @@
---
description: Yii 与 xk-api 接口响应取值规范(按后端固定写死,禁止混取)
alwaysApply: true
---
# 接口响应取值Yii vs xk-api
写请求代码时**先确认该接口走哪个后端**,再按对应字段固定取值。
**禁止**运行时猜测(如「有 code 就取 result」**禁止** `result || data` / `code ?? errcode` 混写兼容。
## 形态对照
| | YiioldApi | xk-apiLaravel jok |
|---|---|---|
| 成功码 | `errcode: 0` | `code: 0` |
| 业务数据 | `data` | `result` |
| 提示文案 | `msg` | `message` |
## 强制用法
统一用 `@/utils/api-response.js`,按后端选方法:
```js
// 该接口是 xk-api医生端拦截器多已返回 body
import { unwrapXkApi } from '@/utils/api-response.js'
const { ok, payload, message } = unwrapXkApi(res)
// 该接口是 Yii
import { unwrapYiiApi } from '@/utils/api-response.js'
const { ok, payload, message } = unwrapYiiApi(res)
```
也可直接写死:
```js
// xk-api拦截器已解包时
if (res.code == 0) { const data = res.result }
// Yii
if (res.errcode == 0) { const data = res.data }
```
API 封装文件注释里标明后端。
## 禁止
- 禁止 `unwrapApi` 一类「自动识别后端再取值」
- 禁止 `res.result || res.data`
- 禁止同一处同时判断 `code` 与 `errcode` 做兼容分支

View File

@@ -10,3 +10,4 @@ alwaysApply: true
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
6. 微信小程序 `.vue` 模板(会编译成 WXML禁止使用 `??``?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.`
7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定
8. 接口取值:写代码时先确认接口是 xk-api 还是 Yii再固定用 `code/result/message``errcode/data/msg`(可用 `unwrapXkApi` / `unwrapYiiApi`);禁止运行时猜测、禁止 `result || data`(详见 Api-Response.mdc

View File

@@ -10,3 +10,5 @@ alwaysApply: true
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
6. 微信小程序 `.vue` 模板(会编译成 WXML禁止使用 `??`、`?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.`
7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定
8. 接口取值:写代码时先确认接口是 xk-api 还是 Yii再固定用 `code/result/message` 或 `errcode/data/msg`(可用 `unwrapXkApi` / `unwrapYiiApi`);禁止运行时猜测、禁止 `result || data`(详见 Api-Response.mdc
9. VIP 功能判断:统一用 `@/utils/vip.js` 的 `hasVipPermission` / `fetchStoreVipPermissions` / `VIP_FEATURE`;模板显隐用 `<vip-gate code="medical_record" :permissions="storeVipPermissions">`;禁止各页手写 `permissions.indexOf` 或重复解析 get-current-store-type

View File

@@ -0,0 +1,40 @@
<template>
<!-- VIP 功能显隐有权限才渲染默认插槽 -->
<view v-if="allowed" class="vip-gate">
<slot />
</view>
<view v-else-if="showFallback" class="vip-gate vip-gate--fallback">
<slot name="fallback" />
</view>
</template>
<script>
/**
* 全局 VIP 显隐组件
* 用法:
* <vip-gate code="medical_record" :permissions="storeVipPermissions">...</vip-gate>
* code 也可用 VIP_FEATURE 常量permissions 必须传履约诊所权限列表
*/
import { hasVipPermission } from '@/utils/vip.js'
export default {
name: 'VipGate',
props: {
/** 功能码,如 medical_record */
code: { type: String, default: '' },
/** 履约诊所 permissions 数组 */
permissions: { type: Array, default: null },
/** 无权限时是否渲染 fallback 插槽 */
fallback: { type: Boolean, default: false },
},
computed: {
allowed() {
if (!this.code) return true
return hasVipPermission(this.code, this.permissions)
},
showFallback() {
return !!this.fallback && !this.allowed
},
},
}
</script>

View File

@@ -89,6 +89,10 @@
<view v-if="t.key === 'info'" class="health-card">
<text class="section-title">健康信息</text>
<template v-if="health">
<view class="info-row">
<text class="info-label">现病史</text>
<text class="info-value">{{ historyText(health.present_status, health.present_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">既往史</text>
<text class="info-value">{{ historyText(health.person_status, health.person_history) }}</text>
@@ -104,6 +108,22 @@
<text class="info-label">家族遗传史</text>
<text class="info-value">{{ historyText(health.family_status, health.family_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">流行病学史</text>
<text class="info-value">{{ historyText(health.epidemic_status, health.epidemic_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">个人史</text>
<text class="info-value">{{ historyText(health.personal_status, health.personal_history) }}</text>
</view>
<view class="info-row" v-if="showMenstrualHistory">
<text class="info-label">月经史</text>
<text class="info-value">{{ historyText(health.menstrual_status, health.menstrual_history) }}</text>
</view>
<view class="info-row" v-if="showMaritalHistory">
<text class="info-label">婚育史</text>
<text class="info-value">{{ historyText(health.marital_status, health.marital_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">肝功能异常</text>
<text
@@ -171,7 +191,7 @@
<template v-else-if="t.key === 'prescription'">
<text class="r-title">{{ item.prescription_no || '—' }}</text>
<text class="r-sub">{{ item.store || '—' }} · {{ item.doctor || '—' }}</text>
<text class="r-sub">{{ item.clinical_diagnose || '—' }} · {{ item.created_at || '' }}</text>
<text class="r-sub">{{ item.is_pay == 1 ? '已支付' : '未支付' }} · {{ item.clinical_diagnose || '—' }} · {{ item.created_at || '' }}</text>
</template>
<template v-else>
<text class="r-title">{{ item.order_no || '—' }}</text>
@@ -298,6 +318,16 @@ export default {
if (Number(sex) === 2) return 'female';
return '';
},
/** 月经史仅女 */
showMenstrualHistory() {
return Number((this.profile.patient && this.profile.patient.sex) || 0) === 2;
},
/** 婚育史:男>22 或 女>20 */
showMaritalHistory() {
const sex = Number((this.profile.patient && this.profile.patient.sex) || 0);
const age = Number((this.profile.patient && this.profile.patient.age) || 0);
return (sex === 1 && age > 22) || (sex === 2 && age > 20);
},
/**
* 回访拨号:只用就诊人 call_mobile拦截器只解密不脱敏
* 不用脱敏后的 mobile也不回退微信用户号

View File

@@ -69,6 +69,10 @@
<view v-if="t.key === 'info'" class="health-card">
<text class="section-title">健康信息</text>
<template v-if="health">
<view class="info-row">
<text class="info-label">现病史</text>
<text class="info-value">{{ historyText(health.present_status, health.present_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">既往史</text>
<text class="info-value">{{ historyText(health.person_status, health.person_history) }}</text>
@@ -84,6 +88,22 @@
<text class="info-label">家族遗传史</text>
<text class="info-value">{{ historyText(health.family_status, health.family_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">流行病学史</text>
<text class="info-value">{{ historyText(health.epidemic_status, health.epidemic_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">个人史</text>
<text class="info-value">{{ historyText(health.personal_status, health.personal_history) }}</text>
</view>
<view class="info-row" v-if="showMenstrualHistory">
<text class="info-label">月经史</text>
<text class="info-value">{{ historyText(health.menstrual_status, health.menstrual_history) }}</text>
</view>
<view class="info-row" v-if="showMaritalHistory">
<text class="info-label">婚育史</text>
<text class="info-value">{{ historyText(health.marital_status, health.marital_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">肝功能异常</text>
<text
@@ -152,7 +172,7 @@
<template v-else-if="t.key === 'prescription'">
<text class="r-title">{{ item.prescription_no || '—' }}</text>
<text class="r-sub">{{ item.store || '—' }} · {{ item.doctor || '—' }}</text>
<text class="r-sub">{{ item.clinical_diagnose || '—' }} · {{ item.created_at || '' }}</text>
<text class="r-sub">{{ item.is_pay == 1 ? '已支付' : '未支付' }} · {{ item.clinical_diagnose || '—' }} · {{ item.created_at || '' }}</text>
</template>
<template v-else>
<text class="r-title">{{ item.order_no || '—' }}</text>
@@ -264,6 +284,16 @@ export default {
if (Number(sex) === 2) return 'female';
return '';
},
/** 月经史仅女 */
showMenstrualHistory() {
return Number((this.profile.patient && this.profile.patient.sex) || 0) === 2;
},
/** 婚育史:男>22 或 女>20 */
showMaritalHistory() {
const sex = Number((this.profile.patient && this.profile.patient.sex) || 0);
const age = Number((this.profile.patient && this.profile.patient.age) || 0);
return (sex === 1 && age > 22) || (sex === 2 && age > 20);
},
/**
* 回访拨号:只用就诊人 call_mobile拦截器只解密不脱敏
* 不用脱敏后的 mobile也不回退微信用户号

View File

@@ -15,7 +15,13 @@
<u-icon margin-left="8" label-size="32" label-color="#6C7380"
:name="require('@/static/image/ys.png')" :label="item.prescription_no"
color="#6ACDBB" size="32"></u-icon>
<d-text :text="statusName[item.status]"></d-text>
<view class="flex-row flex-ali-center">
<text
class="pay-tag"
:class="{ 'pay-tag--paid': item.is_pay == 1 }"
>{{ item.is_pay == 1 ? '已支付' : '未支付' }}</text>
<d-text :text="statusName[item.status]"></d-text>
</view>
</view>
<view class="flex-row flex-ali-center flex-jus-sp m-t-2">
<d-text className="content-c fs-32" :text="getInfo(item.patient)"></d-text>
@@ -189,4 +195,12 @@
.search {
padding: 16rpx 32rpx;
}
.pay-tag {
margin-right: 16rpx;
font-size: 24rpx;
color: #F59E0B;
}
.pay-tag--paid {
color: #10B981;
}
</style>

View File

@@ -404,6 +404,41 @@
@overlay-change="onAiOverlayChange"
@import="onAiConfirmImport"
/>
<!-- 档案史与病历史不一致自定义预览确认不用原生 showModal文案会超长 -->
<u-modal
v-model="historyImportShow"
:show-cancel-button="true"
:mask-close-able="false"
title="导入患者档案病史?"
confirm-text="确认导入"
cancel-text="暂不导入"
@confirm="onConfirmHistoryImport"
@cancel="onCancelHistoryImport"
>
<view class="hist-import-body">
<text class="hist-import-tip">就诊人档案病史与当前病历不一致确认后用档案内容覆盖对应字段仍需保存病历</text>
<scroll-view scroll-y class="hist-import-scroll">
<view
v-for="row in historyImportRows"
:key="row.field"
class="hist-import-card"
>
<text class="hist-import-label">{{ row.label }}</text>
<view class="hist-import-cols">
<view class="hist-import-col">
<text class="hist-import-tag">当前病历</text>
<text class="hist-import-val">{{ row.current || '无' }}</text>
</view>
<view class="hist-import-col hist-import-col--import">
<text class="hist-import-tag hist-import-tag--import">将导入</text>
<text class="hist-import-val">{{ row.import || '无' }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</u-modal>
</view>
</template>
<script>
@@ -558,6 +593,11 @@ export default {
hydrating: false,
// AI写病历抽屉显隐
aiShow: false,
/** 档案史导入确认弹窗 */
historyImportShow: false,
historyImportRows: [],
/** 本面板实例内已提示过的挂号 id避免切 Tab 反复弹 */
_historyImportAskedMap: {},
_draftTimer: null,
areaStyle: { background: '#f4f6f8', borderRadius: '12rpx', padding: '18rpx', minHeight: '80rpx', fontSize: '28rpx', color: '#1a2530', lineHeight: '1.6' },
leftFields: [
@@ -660,6 +700,9 @@ export default {
aiShow() {
this.emitOverlayChange()
},
historyImportShow() {
this.emitOverlayChange()
},
},
methods: {
/**
@@ -675,7 +718,7 @@ export default {
* 通知父页:病历侧是否有弹层打开(与处方弹层共用返回拦截)
*/
emitOverlayChange() {
this.$emit('overlay-change', !!(this.citeShow || this.entryShow || this.aiShow))
this.$emit('overlay-change', !!(this.citeShow || this.entryShow || this.aiShow || this.historyImportShow))
},
/**
* 供父页返回键先关抽屉(不退开方页)
@@ -684,6 +727,7 @@ export default {
this.citeShow = false
this.entryShow = false
this.entryTcmType = ''
this.historyImportShow = false
this.closeAiModal()
},
closeAiModal() {
@@ -794,6 +838,7 @@ export default {
},
async load() {
this.hydrating = true
let historyDiff = []
try {
const mrParams = { register_id: this.registerId }
const mrStoreId = Number(this.storeId || 0)
@@ -801,7 +846,11 @@ export default {
const res = await getMedicalRecordApi(mrParams)
// Laravel jok 在 result兼容 data / 已解包
const data = (res && (res.result != null ? res.result : res.data)) || res || {}
historyDiff = Array.isArray(data.history_import_diff) ? data.history_import_diff : []
this.form = Object.assign(emptyForm(), data)
// 提示字段不进表单/草稿,避免脏数据
delete this.form.history_import_diff
delete this.form.patient_history_defaults
const draft = PrescriptionStorage.loadMedicalRecordDraft(this.registerId)
if (draft) {
this.applyDraft(draft)
@@ -827,9 +876,68 @@ export default {
this.hydrating = false
this.$nextTick(() => {
this.emitSyncToPrescription()
this.maybeAskHistoryImport(historyDiff)
})
}
},
/** 空串与「无」视为同一默认态 */
normalizeHistoryCompare(value) {
const t = value == null ? '' : String(value).trim()
return t === '' || t === '无' ? '无' : t
},
/**
* 草稿合并后对比档案史与当前表单;有差异则弹自定义预览确认
*/
maybeAskHistoryImport(rawDiff) {
const rid = Number(this.registerId || 0)
if (!rid || !Array.isArray(rawDiff) || !rawDiff.length) return
if (this._historyImportAskedMap && this._historyImportAskedMap[rid]) return
const rows = []
for (let i = 0; i < rawDiff.length; i++) {
const item = rawDiff[i]
if (!item || !item.field) continue
const current = this.normalizeHistoryCompare(this.form[item.field])
const importVal = this.normalizeHistoryCompare(item.import)
if (current === importVal) continue
rows.push({
field: item.field,
label: item.label || item.field,
current: current,
import: importVal,
})
}
if (!rows.length) return
this.historyImportRows = rows
this.historyImportShow = true
},
/** 确认导入:只改表单草稿,不自动落库 */
onConfirmHistoryImport() {
const rows = this.historyImportRows || []
const patch = {}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row && row.field) {
patch[row.field] = row.import || '无'
}
}
this.patchFields(patch)
const rid = Number(this.registerId || 0)
if (rid) {
this._historyImportAskedMap[rid] = 1
}
this.historyImportShow = false
this.historyImportRows = []
uni.showToast({ title: '已导入档案病史', icon: 'none' })
},
/** 暂不导入:本会话不再提示 */
onCancelHistoryImport() {
const rid = Number(this.registerId || 0)
if (rid) {
this._historyImportAskedMap[rid] = 1
}
this.historyImportShow = false
this.historyImportRows = []
},
getPayload() {
return Object.assign({}, this.form, {
register_id: Number(this.registerId),
@@ -1834,4 +1942,65 @@ export default {
.ai-btn--disabled {
opacity: 0.45;
}
/* 档案史导入确认预览 */
.hist-import-body {
padding: 8rpx 24rpx 16rpx;
}
.hist-import-tip {
display: block;
font-size: 24rpx;
color: #64748b;
line-height: 1.5;
margin-bottom: 16rpx;
}
.hist-import-scroll {
max-height: 520rpx;
}
.hist-import-card {
background: #f8fafc;
border-radius: 12rpx;
padding: 16rpx;
margin-bottom: 12rpx;
}
.hist-import-label {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #0f172a;
margin-bottom: 12rpx;
}
.hist-import-cols {
display: flex;
flex-direction: row;
}
.hist-import-col {
flex: 1;
background: #fff;
border-radius: 8rpx;
padding: 12rpx;
border: 1rpx solid #e2e8f0;
margin-right: 12rpx;
}
.hist-import-col--import {
margin-right: 0;
border-color: #6acdbb;
background: #f0faf8;
}
.hist-import-tag {
display: block;
font-size: 20rpx;
color: #94a3b8;
margin-bottom: 6rpx;
}
.hist-import-tag--import {
color: #0d9488;
font-weight: 600;
}
.hist-import-val {
display: block;
font-size: 24rpx;
color: #0f172a;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -805,6 +805,11 @@ import { getChatRoomByRegisterApi, sendToUserHttpApi } from '@/api/chat.js';
import { PrescriptionStorage } from './utils/prescriptionStorage.js';
import { PrescriptionCalculator } from './utils/prescriptionCalculator.js';
import { PrescriptionValidator } from './utils/prescriptionValidator.js';
import {
hasVipPermission,
parseVipPermissionsFromRes,
VIP_FEATURE,
} from '@/utils/vip.js';
import DiagnosisModal from './components/modals/DiagnosisModal.vue';
import DoctorOrderModal from './components/modals/DoctorOrderModal.vue';
import MedicalRecordPanel from './components/MedicalRecordPanel.vue';
@@ -978,6 +983,8 @@ export default {
specialPrescriptionRecord: null,
applyingSpecialPrescription: false,
pendingImportSpecial: false,
/** 外部指定初始 Tab如导入健康史后进病历 */
pendingMainTab: '',
/** 医生已导入的特色方 ID仅 >0 时提交走特色方计价 */
appliedSpecialPrescriptionId: 0,
appliedSpecialPrescriptionFreeShipping: false,
@@ -1214,15 +1221,15 @@ export default {
},
/** 门店 VIPAI辅助出方 */
canUseAiPrescription() {
return this.hasStoreVipPermission('ai_prescription');
return this.hasStoreVipPermission(VIP_FEATURE.AI_PRESCRIPTION);
},
/** 门店 VIPAI写病历 */
canUseAiMedicalRecord() {
return this.hasStoreVipPermission('ai_medical_record');
return this.hasStoreVipPermission(VIP_FEATURE.AI_MEDICAL_RECORD);
},
/** 门店 VIP金方导入 */
canUseGoldenFormula() {
return this.hasStoreVipPermission('golden_formula');
return this.hasStoreVipPermission(VIP_FEATURE.GOLDEN_FORMULA);
},
navbarTitle() {
if (this.isSalespersonTransferMode) {
@@ -1317,6 +1324,8 @@ export default {
this.patientId = options.patient_id || '';
this.pendingReusePrescriptionId = options.reuse_prescription_id ? String(options.reuse_prescription_id) : '';
this.pendingImportSpecial = String(options.import_special) === '1';
// 从患者详情「导入到病历」进入时强制切病历 Tab
this.pendingMainTab = String(options.tab || '') === 'mr' ? 'mr' : '';
this.isCommonPrescription = String(options.save_common) === '1';
if (!this.isCommonPrescription) {
uni.removeStorageSync('add');
@@ -1770,22 +1779,18 @@ export default {
this.closePreSendPriceAdjust();
},
/**
* 判断当前诊所 VIP 是否含指定权益码
* 判断当前诊所 VIP 是否含指定权益码(统一走全局 hasVipPermission
*/
hasStoreVipPermission(code) {
const list = this.storeVipPermissions;
return Array.isArray(list) && list.indexOf(code) >= 0;
return hasVipPermission(code, this.storeVipPermissions);
},
/**
* 从门店类型接口结果写入 VIP permissions
* 有 medical_record 才显示病历 Tab无则静默留在处方不打 get-medical-record 避免弹 VIP 错)
*/
applyStoreVipFromRes(res) {
const data = (res && (res.result != null ? res.result : res.data != null ? res.data : res)) || {};
const vip = data.vip || {};
const perms = vip.permissions;
this.storeVipPermissions = Array.isArray(perms) ? perms.slice() : [];
const allowMr = this.hasStoreVipPermission('medical_record');
this.storeVipPermissions = parseVipPermissionsFromRes(res);
const allowMr = hasVipPermission(VIP_FEATURE.MEDICAL_RECORD, this.storeVipPermissions);
this.canUseMedicalRecord = allowMr;
if (allowMr) {
this.restoreMainTab();
@@ -1965,12 +1970,18 @@ export default {
});
}
},
/** 从本地恢复外层 Tab */
/** 从本地恢复外层 TabURL tab=mr 优先一次 */
restoreMainTab() {
if (!this.registerId || !this.canUseMedicalRecord) {
this.mainTab = 'rx';
return;
}
if (this.pendingMainTab === 'mr') {
this.mainTab = 'mr';
this.pendingMainTab = '';
PrescriptionStorage.saveRxMrTab('mr', this.registerId);
return;
}
const saved = PrescriptionStorage.loadRxMrTab(this.registerId);
this.mainTab = saved || 'rx';
},
@@ -3623,7 +3634,7 @@ export default {
}
/* 整页纵向 flex顶部 chrome 不收缩swiper 吃满剩余高度 */
.page-flex {
height: 110vh;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;

View File

@@ -0,0 +1,65 @@
/**
* 就诊人健康问诊 → 病历「史」字段
* 与 PC / 后端 resolveHistoryDefaultsFromPatient 映射一致
* present→现病史person→既往史allergic→过敏史family→家族史其余同名
*/
const HISTORY_MAP = [
{ statusKey: 'present_status', historyKey: 'present_history', mrKey: 'present_illness', visibility: 'always' },
{ statusKey: 'person_status', historyKey: 'person_history', mrKey: 'past_history', visibility: 'always' },
{ statusKey: 'allergic_status', historyKey: 'allergic_history', mrKey: 'allergy_history', visibility: 'always' },
{ statusKey: 'family_status', historyKey: 'family_history', mrKey: 'family_history', visibility: 'always' },
{ statusKey: 'epidemic_status', historyKey: 'epidemic_history', mrKey: 'epidemic_history', visibility: 'always' },
{ statusKey: 'personal_status', historyKey: 'personal_history', mrKey: 'personal_history', visibility: 'always' },
{ statusKey: 'menstrual_status', historyKey: 'menstrual_history', mrKey: 'menstrual_history', visibility: 'menstrual' },
{ statusKey: 'marital_status', historyKey: 'marital_history', mrKey: 'marital_history', visibility: 'marital' },
]
function isFieldVisible(visibility, sex, age) {
if (visibility === 'menstrual') return Number(sex) === 2
if (visibility === 'marital') {
const s = Number(sex)
const a = Number(age) || 0
return (s === 1 && a > 22) || (s === 2 && a > 20)
}
return true
}
function resolveHistoryText(status, raw) {
if (Number(status) !== 1) return '无'
if (Array.isArray(raw)) {
const parts = raw.map((v) => String(v == null ? '' : v).trim()).filter(Boolean)
return parts.length ? parts.join('、') : '无'
}
const str = String(raw == null ? '' : raw).trim()
if (!str || str === '[]' || str === 'null') return '无'
try {
const decoded = JSON.parse(str)
if (Array.isArray(decoded)) {
const parts = decoded.map((v) => String(v == null ? '' : v).trim()).filter(Boolean)
return parts.length ? parts.join('、') : '无'
}
} catch (e) {
/* ignore */
}
const parts = str.split(/[,,、]+/).map((s) => s.trim()).filter(Boolean)
return parts.length ? parts.join('、') : '无'
}
/**
* @param {Record<string, any>|null|undefined} health
* @param {{ sex?: number, age?: number }} [opts]
* @returns {Record<string, string>}
*/
export function mapHealthInquiryToMrHistory(health, opts) {
const hi = health || {}
const sex = Number((opts && opts.sex) || 0)
const age = Number((opts && opts.age) || 0)
const out = {}
for (let i = 0; i < HISTORY_MAP.length; i++) {
const row = HISTORY_MAP[i]
if (!isFieldVisible(row.visibility, sex, age)) continue
out[row.mrKey] = resolveHistoryText(hi[row.statusKey], hi[row.historyKey])
}
return out
}

View File

@@ -120,20 +120,48 @@
</view>
</view>
<!-- 健康信息 -->
<!-- 健康信息与就诊人档案七类史 + 肝肾对齐有病历 VIP 可导入 -->
<view class="modern-card">
<view class="card-title">健康信息</view>
<view class="card-title-row flex-row flex-jus-sp flex-ali-center">
<view class="card-title">健康信息</view>
<vip-gate :code="vipFeatureMedicalRecord" :permissions="storeVipPermissions">
<view
class="health-import-btn"
@click="onImportHealthToMedicalRecord"
>导入到病历</view>
</vip-gate>
</view>
<view class="info-row">
<text class="info-label">现病史</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'present') }}</text>
</view>
<view class="info-row">
<text class="info-label">既往史</text>
<text class="info-value">{{infoList.healthInquery.person_status==0?'无':infoList.healthInquery.person_history}}</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'person') }}</text>
</view>
<view class="info-row">
<text class="info-label">过敏史</text>
<text class="info-value" :class="{'error-text font-bold': infoList.healthInquery.allergic_status!=0}">{{infoList.healthInquery.allergic_status==0?'无':infoList.healthInquery.allergic_history}}</text>
<text class="info-value" :class="{'error-text font-bold': infoList.healthInquery.allergic_status!=0}">{{ healthHistoryText(infoList.healthInquery, 'allergic') }}</text>
</view>
<view class="info-row">
<text class="info-label">家族遗传史</text>
<text class="info-value">{{infoList.healthInquery.family_status==0?'无':infoList.healthInquery.family_history}}</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'family') }}</text>
</view>
<view class="info-row">
<text class="info-label">流行病学史</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'epidemic') }}</text>
</view>
<view class="info-row">
<text class="info-label">个人史</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'personal') }}</text>
</view>
<view class="info-row" v-if="showMenstrualHistory">
<text class="info-label">月经史</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'menstrual') }}</text>
</view>
<view class="info-row" v-if="showMaritalHistory">
<text class="info-label">婚育史</text>
<text class="info-value">{{ healthHistoryText(infoList.healthInquery, 'marital') }}</text>
</view>
<view class="info-row">
<text class="info-label">肝功能异常</text>
@@ -173,6 +201,14 @@
<u-icon name="file-text" color="#00A88A" size="32" class="m-r-8"></u-icon>
<text class="rx-no font-bold color-title">{{item.prescription_no}}</text>
</view>
<text
class="rx-pay-tag"
:class="{
'rx-pay-tag--paid': item.order_is_pay == 1,
'rx-pay-tag--wait': item.order_pay_text == '待支付',
'rx-pay-tag--muted': item.order_pay_text == '无订单' || item.order_pay_text == '已取消'
}"
>{{ item.order_pay_text || payTextByIsPay(item.is_pay) }}</text>
</view>
<view class="rx-actions flex-row flex-jus-end m-t-16">
<view v-if="item.can_withdraw" class="action-btn-ghost danger" @click="withdrawRxByIndex('current', rxIdx)">撤回</view>
@@ -202,6 +238,14 @@
<u-icon name="file-text" color="#8A92A3" size="32" class="m-r-8"></u-icon>
<text class="rx-no color-title">{{item.prescription_no}}</text>
</view>
<text
class="rx-pay-tag"
:class="{
'rx-pay-tag--paid': item.order_is_pay == 1,
'rx-pay-tag--wait': item.order_pay_text == '待支付',
'rx-pay-tag--muted': item.order_pay_text == '无订单' || item.order_pay_text == '已取消'
}"
>{{ item.order_pay_text || payTextByIsPay(item.is_pay) }}</text>
</view>
<view class="rx-actions flex-row flex-jus-end m-t-16">
<view v-if="item.can_withdraw" class="action-btn-ghost danger" @click="withdrawRxByIndex('history', idx)">撤回</view>
@@ -252,18 +296,36 @@ import {
getPatientPrescriptionHistory,
withdrawPrescriptionWx,
receptionApi,
endOfDiagnosisApi
endOfDiagnosisApi,
} from '@/api/reception.js'
import { pickSpecialPrescriptionRecord, resolveSpecialPrescriptionPurchaseLabel } from '@/subPackages/sub_workbench/prescription_v2/utils/specialPrescription.js'
import { mapHealthInquiryToMrHistory } from '@/subPackages/sub_workbench/prescription_v2/utils/mapHealthInquiryToMrHistory.js'
import { PrescriptionStorage } from '@/subPackages/sub_workbench/prescription_v2/utils/prescriptionStorage.js'
import {
fetchStoreVipPermissions,
hasVipPermission,
VIP_FEATURE,
} from '@/utils/vip.js'
import VipGate from '@/components/vip-gate/vip-gate.vue'
function emptyHealthInquiry() {
return {
present_status: 0,
present_history: '',
person_status: 0,
person_history: '',
allergic_status: 0,
allergic_history: '',
family_status: 0,
family_history: '',
epidemic_status: 0,
epidemic_history: '',
personal_status: 0,
personal_history: '',
menstrual_status: 0,
menstrual_history: '',
marital_status: 0,
marital_history: '',
liver_function: 0,
renal_function: 0
}
@@ -295,8 +357,13 @@ function normalizeRegisterDetail(raw) {
}
export default {
components: {
VipGate,
},
data() {
return {
/** 给模板 vip-gate 用的功能码(模板禁止直接写导入常量较别扭) */
vipFeatureMedicalRecord: VIP_FEATURE.MEDICAL_RECORD,
flag: true,
item: {},
status: "loading",
@@ -318,14 +385,31 @@ export default {
rxHistoryNextPage: 1,
rxCurrentHasMore: true,
rxHistoryHasMore: true,
rxLoadingMore: false
rxLoadingMore: false,
/** 履约诊所 VIP permissions全局判断用 */
storeVipPermissions: [],
};
},
computed: {
/** 是否开通病历 VIP统一 hasVipPermission */
canUseMedicalRecord() {
return hasVipPermission(VIP_FEATURE.MEDICAL_RECORD, this.storeVipPermissions)
},
/** 特色方卡片规格SKU 名或单剂 */
specialPrescriptionPurchaseLabel() {
return resolveSpecialPrescriptionPurchaseLabel(this.infoList?.special_prescription_patient_record)
},
/** 月经史仅女 */
showMenstrualHistory() {
const sex = Number((this.infoList.patient && this.infoList.patient.sex) || 0)
return sex === 2
},
/** 婚育史:男>22 或 女>20 */
showMaritalHistory() {
const sex = Number((this.infoList.patient && this.infoList.patient.sex) || 0)
const age = Number((this.infoList.patient && this.infoList.patient.age) || 0)
return (sex === 1 && age > 22) || (sex === 2 && age > 20)
},
},
onLoad(op) {
this.id = op["id"]
@@ -341,6 +425,21 @@ export default {
}
},
methods: {
/**
* 健康史展示status=0 无;=1 展示内容
* prefix: person / allergic / family / epidemic / personal / menstrual / marital
*/
healthHistoryText(hi, prefix) {
const h = hi || {}
const status = Number(h[prefix + '_status'] || 0)
if (status !== 1) return '无'
const text = h[prefix + '_history']
return text != null && String(text).trim() !== '' ? String(text) : '有'
},
/** 处方表 is_pay 兜底文案(接口无 order_pay_text 时) */
payTextByIsPay(isPay) {
return Number(isPay) === 1 ? '已支付' : '未支付'
},
apiOk(res) {
return res && (res.code === 0 || res.errcode === 0)
},
@@ -495,7 +594,7 @@ export default {
* @param {number|string} patientId 就诊人 ID
* @param {boolean} importSpecial 是否自动导入特色方
*/
buildPrescriptionV2Url(registerId, patientId, importSpecial = false) {
buildPrescriptionV2Url(registerId, patientId, importSpecial = false, tab = '') {
let url = `/subPackages/sub_workbench/prescription_v2/index?register_id=${registerId}`;
if (patientId) {
url += `&patient_id=${patientId}`;
@@ -503,8 +602,56 @@ export default {
if (importSpecial) {
url += '&import_special=1';
}
if (tab === 'mr') {
url += '&tab=mr';
}
return url;
},
/**
* 拉取履约诊所 VIP permissions全局 fetchStoreVipPermissions
*/
async refreshMedicalRecordVip() {
const storeId = Number((this.infoList && this.infoList.store_id) || 0)
const registerId = Number((this.infoList && this.infoList.id) || 0)
this.storeVipPermissions = await fetchStoreVipPermissions({ storeId, registerId })
},
/**
* 健康档案导入病历:写入本地草稿后跳转开方页病历 Tab
*/
onImportHealthToMedicalRecord() {
if (!this.canUseMedicalRecord) {
this.$toast('未开通病历功能')
return
}
const registerId = Number((this.infoList && this.infoList.id) || 0)
const patientId = Number((this.infoList && this.infoList.user_patient_id) || 0)
if (!registerId) {
this.$toast('挂号信息无效')
return
}
if (Number(this.infoList.status) !== 2) {
this.$toast('请先接诊后再导入病历')
return
}
uni.showModal({
title: '导入到病历',
content: '将用健康档案病史覆盖病历对应字段,导入后请检查并保存病历。',
confirmColor: '#00A88A',
success: (r) => {
if (!r.confirm) return
const patient = (this.infoList && this.infoList.patient) || {}
const fields = mapHealthInquiryToMrHistory(this.infoList.healthInquery, {
sex: patient.sex,
age: patient.age,
})
const draft = PrescriptionStorage.loadMedicalRecordDraft(registerId) || {}
PrescriptionStorage.saveMedicalRecordDraft(registerId, Object.assign({}, draft, fields))
PrescriptionStorage.saveRxMrTab('mr', registerId)
this.$toast('已写入病历草稿')
this.$go(this.buildPrescriptionV2Url(registerId, patientId, false, 'mr'))
},
})
},
/**
* 延迟跳转开方页(接诊成功后统一入口)
*/
@@ -567,6 +714,7 @@ export default {
}
const raw = res.result || res.data || res
this.infoList = normalizeRegisterDetail(raw)
this.refreshMedicalRecordVip()
if (this.infoList.status !== 7) {
if (this.rxTabIndex === 0) {
this.loadRxCurrent(true)
@@ -679,11 +827,14 @@ page {
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(42, 46, 53, 0.04);
.card-title-row {
margin-bottom: 24rpx;
}
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #2A2E35;
margin-bottom: 24rpx;
margin-bottom: 0;
display: flex;
align-items: center;
position: relative;
@@ -699,6 +850,14 @@ page {
border-radius: 4rpx;
}
}
.health-import-btn {
font-size: 24rpx;
color: #00A88A;
padding: 8rpx 20rpx;
border: 1rpx solid #00A88A;
border-radius: 28rpx;
flex-shrink: 0;
}
}
/* Info Rows */
@@ -764,6 +923,21 @@ page {
font-size: 28rpx;
}
}
.rx-pay-tag {
font-size: 24rpx;
color: #F59E0B;
flex-shrink: 0;
margin-left: 16rpx;
}
.rx-pay-tag--paid {
color: #10B981;
}
.rx-pay-tag--wait {
color: #F59E0B;
}
.rx-pay-tag--muted {
color: #94A3B8;
}
/* Modern Ghost Buttons */
.action-btn-ghost {

78
utils/api-response.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* 按「接口所属后端」固定取值,禁止运行时猜测 / result||data 混取
*
* 写代码时先确认该请求打的是 xk-api 还是 Yii再选用对应方法
* - xk-apiunwrapXkApi / xkOk / xkPayload / xkMessage → code / result / message
* - Yii unwrapYiiApi / yiiOk / yiiPayload / yiiMessage → errcode / data / msg
*
* 医生端拦截器多数已返回 body也兼容完整 uni 响应(业务在 res.data
*/
/** 取出业务 body */
export function getApiBody(res) {
if (res == null || typeof res !== 'object') {
return res
}
if ('statusCode' in res || 'errMsg' in res) {
return res.data
}
return res
}
/** xk-api 是否成功code === 0 */
export function xkOk(res) {
const body = getApiBody(res)
return !!(body && Number(body.code) === 0)
}
/** xk-api 业务数据(固定取 result */
export function xkPayload(res) {
const body = getApiBody(res)
return body ? body.result : undefined
}
/** xk-api 提示文案(固定取 message */
export function xkMessage(res) {
const body = getApiBody(res)
return (body && body.message) || ''
}
/** xk-api 一次解包 */
export function unwrapXkApi(res) {
const body = getApiBody(res)
return {
ok: xkOk(res),
payload: xkPayload(res),
message: xkMessage(res),
body: body,
}
}
/** Yii 是否成功errcode === 0 */
export function yiiOk(res) {
const body = getApiBody(res)
return !!(body && Number(body.errcode) === 0)
}
/** Yii 业务数据(固定取 data */
export function yiiPayload(res) {
const body = getApiBody(res)
return body ? body.data : undefined
}
/** Yii 提示文案(固定取 msg */
export function yiiMessage(res) {
const body = getApiBody(res)
return (body && body.msg) || ''
}
/** Yii 一次解包 */
export function unwrapYiiApi(res) {
const body = getApiBody(res)
return {
ok: yiiOk(res),
payload: yiiPayload(res),
message: yiiMessage(res),
body: body,
}
}

87
utils/vip.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* VIP 权限判断(医生小程序全局方法)
* - 履约诊所 permissions 来自 get-current-store-type 的 vip.permissions
* - 业务里用 hasVipPermission(code, permissions) / fetchStoreVipPermissions
* - 模板显隐可用 components/vip-gate/vip-gate.vue
*/
import { getCurrentStoreTypeApi } from '@/api/reception.js'
/** 与后台 xk_vip_feature.code 对齐 */
export const VIP_FEATURE = {
MEDICAL_RECORD: 'medical_record',
AI_MEDICAL_RECORD: 'ai_medical_record',
AI_PRESCRIPTION: 'ai_prescription',
GOLDEN_FORMULA: 'golden_formula',
}
/**
* 从接口响应解出业务体(兼容 result / data / 已解包)
*/
function unwrapPayload(res) {
if (!res || typeof res !== 'object') return {}
if (res.result != null && typeof res.result === 'object') return res.result
if (res.data != null && typeof res.data === 'object') return res.data
return res
}
/**
* 从 get-current-store-type 响应取出 permissions 数组
* @param {any} res
* @returns {string[]}
*/
export function parseVipPermissionsFromRes(res) {
const data = unwrapPayload(res)
const vip = data && data.vip
const list = vip && Array.isArray(vip.permissions) ? vip.permissions : []
return list.filter((x) => typeof x === 'string' && x)
}
/**
* 从响应取出完整 vip 对象
* @param {any} res
* @returns {object|null}
*/
export function pickVipFromRes(res) {
const data = unwrapPayload(res)
const vip = data && data.vip
return vip && typeof vip === 'object' ? vip : null
}
/**
* 是否拥有指定 VIP 功能码
* @param {string} code
* @param {string[]|object|null|undefined} vipOrPermissions vip 对象或 permissions 数组(医生端必须传履约诊所)
*/
export function hasVipPermission(code, vipOrPermissions) {
if (!code) return false
if (vipOrPermissions == null) return false
let list = []
if (Array.isArray(vipOrPermissions)) {
list = vipOrPermissions
} else if (typeof vipOrPermissions === 'object') {
list = Array.isArray(vipOrPermissions.permissions) ? vipOrPermissions.permissions : []
}
return list.indexOf(code) >= 0
}
/**
* 拉取履约诊所 VIP permissions统一入口避免各页复制 getCurrentStoreType
* @param {{ storeId?: number|string, registerId?: number|string }} opts
* @returns {Promise<string[]>}
*/
export async function fetchStoreVipPermissions(opts) {
const storeId = Number((opts && opts.storeId) || 0)
const registerId = Number((opts && opts.registerId) || 0)
const params = {}
if (storeId > 0) params.store_id = storeId
if (registerId > 0) params.register_id = registerId
if (!params.store_id && !params.register_id) {
return []
}
try {
const res = await getCurrentStoreTypeApi(params)
return parseVipPermissionsFromRes(res)
} catch (e) {
return []
}
}