feat: VIP功能绑定、VIP病历模块、字典模块

This commit is contained in:
李琦
2026-08-05 10:45:38 +08:00
parent 1f8803a971
commit d1b7e75a43
11 changed files with 1476 additions and 49 deletions

View File

@@ -493,3 +493,48 @@ export function getDrugsByRegisterIdApi(data) {
}
})
}
/** 获取病历 */
export function getMedicalRecordApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/get-medical-record',
method: 'GET',
data
})
}
/** 保存病历 */
export function saveMedicalRecordApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/save-medical-record',
method: 'POST',
data
})
}
/** 清空病历 */
export function clearMedicalRecordApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/clear-medical-record',
method: 'POST',
data
})
}
/** 引用最近病历 */
export function latestMedicalRecordApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/latest-medical-record',
method: 'GET',
data
})
}
/** 病历词条检索 */
export function searchMedicalRecordEntryApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/search-medical-record-entry',
method: 'GET',
data
})
}

View File

@@ -28,7 +28,7 @@
:duration="200"
@change="onTabSwiperChange"
>
<swiper-item v-for="t in visibleTabs" :key="'sw-' + t.key">
<swiper-item v-for="t in visibleTabs" :key="t.key">
<scroll-view
scroll-y
class="tab-scroll"
@@ -94,7 +94,7 @@
</view>
<view v-else-if="t.key === 'answers'" class="card">
<view v-if="!answerRecords.length" class="empty-inline">暂无回答</view>
<view v-for="(a, aidx) in answerRecords" :key="'a-' + aidx" class="row answer-row">
<view v-for="(a, aidx) in answerRecords" :key="aidx" class="row answer-row">
<text class="label">{{ a.question || '问题' }}</text>
<text class="value">{{ a.answer || '—' }}</text>
</view>

View File

@@ -0,0 +1,771 @@
<template>
<!--
医生小程序病历面板
诊断/医嘱与处方同源emit 打开父页常用诊断/常用医嘱弹窗
其余字段走病历词条抽屉
模板禁止 ?? ?. :class 方法调用
-->
<view class="mr-panel">
<!-- 三个操作按钮吸顶滚动字段时始终可见 -->
<view class="mr-actions flex-row flex-wrap">
<view class="mr-btn danger" @click="onClear">清空病历</view>
<view class="mr-btn" @click="onCite">引用历史</view>
<view class="mr-btn primary" @click="onSave">保存病历</view>
</view>
<scroll-view scroll-y class="mr-scroll" :enable-back-to-top="true">
<view class="mr-scroll-inner">
<view class="mr-field">
<d-text text="诊断(与处方同步)" className="fs-28 font-bold" color="#333" />
<view class="mr-entry-row" @click="onOpenDiagnosis">
<text class="color-sub fs-24">{{ diagnosisReadonly ? '去选择常用诊断' : '选择常用诊断' }}</text>
</view>
<u-input
v-model="localDiagnosis"
type="textarea"
:auto-height="true"
:disabled="diagnosisReadonly"
:placeholder="diagnosisReadonly ? '请通过常用诊断选择' : '临床诊断'"
:custom-style="areaStyle"
@input="emitDiagnosis"
/>
</view>
<view v-for="item in leftFields" :key="item.code" class="mr-field">
<d-text :text="item.label" className="fs-28 font-bold" color="#333" />
<!-- 中医病案舌脉 辨证 证候|疾病 治法 -->
<view v-if="item.code === 'tcm_case'" class="m-b-8">
<view class="flex-row m-b-8">
<view class="mr-entry-row flex-1 m-r-8" @click="openEntry('tongue')">
<text class="color-sub fs-24">舌象词条</text>
</view>
<view class="mr-entry-row flex-1" @click="openEntry('pulse')">
<text class="color-sub fs-24">脉象词条</text>
</view>
</view>
<view class="flex-row m-b-8">
<u-input v-model="form.tongue" placeholder="舌象" class="flex-1 m-r-8" />
<u-input v-model="form.pulse" placeholder="脉象" class="flex-1" />
</view>
<view class="mr-entry-row m-b-8" @click="openEntry('tcm_case')">
<text class="color-sub fs-24">选择辨证/病案词条</text>
</view>
<u-input
v-model="form.tcm_case"
type="textarea"
:auto-height="true"
placeholder="辨证/病案"
:custom-style="areaStyle"
class="m-b-8"
/>
<view
v-for="row in tcmCaseSubRows"
:key="row.key"
class="flex-row m-b-8"
>
<view
v-for="(sub, colIdx) in row.cols"
:key="sub._key"
class="flex-1"
:class="{ 'm-r-8': colIdx === 0 }"
>
<view v-if="!sub._empty">
<d-text :text="sub.label" className="fs-24" color="#666" />
<view class="mr-entry-row" @click="openTcmEntry(sub)">
<text class="color-sub fs-24">选择{{ sub.label }}</text>
</view>
<u-input v-model="form[sub.code]" :placeholder="sub.label" />
</view>
</view>
</view>
</view>
<view
v-else-if="item.code === 'doctor_order'"
class="mr-entry-row"
@click="onOpenDoctorOrder"
>
<text class="color-sub fs-24">选择常用医嘱</text>
</view>
<view
v-else
class="mr-entry-row"
@click="openEntry(item.code)"
>
<text class="color-sub fs-24">选择{{ item.label }}词条</text>
</view>
<u-input
v-if="item.code === 'doctor_order'"
v-model="localAdvice"
type="textarea"
:auto-height="true"
placeholder="医嘱(与处方同步)"
:custom-style="areaStyle"
@input="emitAdvice"
/>
<u-input
v-else-if="item.code !== 'tcm_case'"
v-model="form[item.code]"
type="textarea"
:auto-height="true"
:placeholder="item.label"
:custom-style="areaStyle"
/>
</view>
<view v-for="item in rightFields" :key="item.code" class="mr-field">
<d-text :text="item.label" className="fs-28 font-bold" color="#333" />
<view class="mr-entry-row" @click="openEntry(item.code)">
<text class="color-sub fs-24">选择{{ item.label }}词条</text>
</view>
<view v-if="item.code === 'physical_exam'" class="vitals m-b-8">
<view class="vital-item">
<text>体温</text>
<u-input v-model="form.temperature" type="digit" placeholder="℃" />
</view>
<view class="vital-item">
<text>身高</text>
<u-input v-model="form.height" type="digit" placeholder="cm" />
</view>
<view class="vital-item">
<text>体重</text>
<u-input v-model="form.weight" type="digit" placeholder="KG" />
</view>
<view class="vital-item">
<text>呼吸</text>
<u-input v-model="form.respiratory_rate" type="number" placeholder="次/分" />
</view>
<view class="vital-item">
<text>血压</text>
<u-input v-model="form.bp_systolic" type="number" placeholder="高" />
<text>/</text>
<u-input v-model="form.bp_diastolic" type="number" placeholder="低" />
</view>
</view>
<u-input
v-model="form[item.code]"
type="textarea"
:auto-height="true"
:placeholder="item.label"
:custom-style="areaStyle"
/>
</view>
</view>
</scroll-view>
<!-- 词条选择抽屉page-container + v-if -->
<page-container
v-if="entryShow"
:show="entryShow"
position="bottom"
round
@clickoverlay="entryShow = false"
@afterleave="entryShow = false"
>
<view class="entry-drawer">
<view class="entry-title-row">
<text class="entry-title">选择词条</text>
<text class="entry-count">{{ entryKeyword ? ('搜到 ' + entryList.length + ' 条') : ('共 ' + entryList.length + ' 条') }}</text>
</view>
<u-input
v-model="entryKeyword"
placeholder="搜索词条名称/内容/首拼"
:custom-style="entrySearchStyle"
@input="onEntryKeywordInput"
/>
<view
v-for="row in entryList"
:key="row.id"
class="entry-row"
@click="pickEntry(row)"
>
<view class="entry-row-top">
<view class="fs-28 font-bold entry-row-name">
<block v-for="(p, pi) in row._titleParts" :key="pi">
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</view>
<text v-if="entryKeyword" class="entry-score">{{ row.match_score || 0 }}%</text>
</view>
<view v-if="row.pinyin_initials" class="fs-22 color-sub m-t-4">
<text>首拼(全拼)</text>
<block v-for="(p, pi) in row._pyParts" :key="pi">
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</view>
<view class="fs-24 color-sub m-t-8">
<block v-for="(p, pi) in row._contentParts" :key="pi">
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</view>
<text v-if="row.remark" class="fs-22 color-sub">{{ row.remark }}</text>
</view>
<view v-if="!entryList.length" class="p-32 color-sub">暂无词条</view>
</view>
</page-container>
</view>
</template>
<script>
/**
* 病历面板:与处方诊断/医嘱双向同步
* 诊断/医嘱选用走父页常用诊断、常用医嘱弹窗
*/
import {
clearMedicalRecordApi,
getMedicalRecordApi,
getTraditionalChineseMedicineJson,
latestMedicalRecordApi,
saveMedicalRecordApi,
searchMedicalRecordEntryApi,
} from '@/api/reception.js'
import { PrescriptionStorage } from '../utils/prescriptionStorage.js'
import {
applyDictRankAndHighlight,
splitHighlight,
} from '../utils/dictSearchRank.js'
function emptyForm() {
return {
chief_complaint: '',
present_illness: '',
tcm_disease: '',
tcm_syndrome: '',
tcm_method: '',
tongue: '',
pulse: '',
tcm_case: '',
treatment_advice: '',
doctor_order: '',
diagnosis: '',
family_history: '',
epidemic_history: '',
past_history: '',
allergy_history: '',
physical_exam: '',
auxiliary_exam: '',
temperature: '',
height: '',
weight: '',
respiratory_rate: '',
bp_systolic: '',
bp_diastolic: '',
user_patient_id: 0,
}
}
/**
* 拼出 首拼(全拼) 展示(与 PC 病历气泡一致)
*/
function formatPyDisplayOf(item) {
if (!item) return ''
const display = String(item.pinyin_display || '').trim()
if (display) return display
const initials = String(item.pinyin_initials || '').trim()
if (initials.indexOf('(') >= 0 && initials.slice(-1) === ')') return initials
const abbr = String(item.pinyin || initials || item.initials_of_pinyin || '').trim()
const full = String(item.pinyin_full || '').trim()
if (!abbr && !full) return ''
if (!full || abbr.toLowerCase() === full.toLowerCase()) return abbr || full
if (!abbr) return '(' + full + ')'
return abbr + '(' + full + ')'
}
export default {
name: 'MedicalRecordPanel',
props: {
registerId: { type: [Number, String], default: 0 },
storeId: { type: [Number, String], default: 0 },
userPatientId: { type: [Number, String], default: 0 },
diagnosis: { type: String, default: '' },
medicalAdvice: { type: String, default: '' },
/** 在线复诊:诊断只读,必须点选 */
diagnosisReadonly: { type: Boolean, default: false },
},
data() {
const tcmCaseSubFields = [
{ code: 'tcm_syndrome', label: '中医证候', tcmType: 'syndrome', _key: 'tcm_syndrome' },
{ code: 'tcm_disease', label: '中医疾病', tcmType: 'diseases', _key: 'tcm_disease' },
{ code: 'tcm_method', label: '中医治法', tcmType: 'method', _key: 'tcm_method' },
]
return {
form: emptyForm(),
localDiagnosis: '',
localAdvice: '',
entryShow: false,
entryField: '',
entryList: [],
entryKeyword: '',
entrySearchTimer: null,
entrySearchStyle: { background: '#f7f8fa', padding: '12rpx 16rpx', marginBottom: '16rpx' },
hydrating: false,
_draftTimer: null,
areaStyle: { background: '#f7f8fa', padding: '16rpx', minHeight: '120rpx' },
leftFields: [
{ code: 'chief_complaint', label: '主诉' },
{ code: 'present_illness', label: '现病史' },
{ code: 'tcm_case', label: '中医病案' },
{ code: 'treatment_advice', label: '治疗意见' },
{ code: 'doctor_order', label: '医嘱' },
],
/** 中医病案内嵌证候→疾病→治法一行两列_key 供小程序 :key 绑定) */
tcmCaseSubFields,
tcmCaseSubRows: [
{
key: 'r0',
cols: [tcmCaseSubFields[0], tcmCaseSubFields[1]],
},
{
key: 'r1',
cols: [tcmCaseSubFields[2], { _key: 'empty', _empty: true }],
},
],
/** 中医三典当前抽屉类型 diseases|syndrome|method空=病历词条 */
entryTcmType: '',
rightFields: [
{ code: 'family_history', label: '家族史' },
{ code: 'epidemic_history', label: '流行病学史' },
{ code: 'past_history', label: '既往史' },
{ code: 'allergy_history', label: '过敏史' },
{ code: 'physical_exam', label: '体征检查' },
{ code: 'auxiliary_exam', label: '辅助检查' },
],
}
},
computed: {
/** props 优先,其次病历回填 */
effectivePatientId() {
return Number(this.userPatientId || this.form.user_patient_id || 0)
},
},
watch: {
registerId: {
immediate: true,
handler(v) {
if (v) this.load()
},
},
diagnosis(v) {
this.localDiagnosis = v || ''
this.schedulePersistDraft()
},
medicalAdvice(v) {
this.localAdvice = v || ''
this.schedulePersistDraft()
},
form: {
deep: true,
handler() {
this.schedulePersistDraft()
},
},
},
methods: {
emitDiagnosis() {
if (this.diagnosisReadonly) return
this.$emit('update:diagnosis', this.localDiagnosis || '')
this.schedulePersistDraft()
},
emitAdvice() {
this.$emit('update:medicalAdvice', this.localAdvice || '')
this.schedulePersistDraft()
},
onOpenDiagnosis() {
this.$emit('open-diagnosis')
},
onOpenDoctorOrder() {
this.$emit('open-doctor-order')
},
schedulePersistDraft() {
if (this.hydrating || !this.registerId) return
if (this._draftTimer) clearTimeout(this._draftTimer)
this._draftTimer = setTimeout(() => {
this.persistDraft()
}, 300)
},
persistDraft() {
if (!this.registerId || this.hydrating) return
PrescriptionStorage.saveMedicalRecordDraft(this.registerId, this.getPayload())
},
applyDraft(draft) {
if (!draft) return
this.form = Object.assign(emptyForm(), draft, {
user_patient_id: this.userPatientId || draft.user_patient_id || 0,
})
if (draft.diagnosis) {
this.localDiagnosis = draft.diagnosis
this.$emit('update:diagnosis', draft.diagnosis)
}
if (draft.doctor_order || draft.medicalAdvice) {
this.localAdvice = draft.doctor_order || draft.medicalAdvice || ''
this.$emit('update:medicalAdvice', this.localAdvice)
}
},
async load() {
this.hydrating = true
try {
const res = await getMedicalRecordApi({
register_id: this.registerId,
store_id: this.storeId || undefined,
})
const data = (res && res.data) || res || {}
this.form = Object.assign(emptyForm(), data)
const draft = PrescriptionStorage.loadMedicalRecordDraft(this.registerId)
if (draft) {
this.applyDraft(draft)
} else {
if (!this.diagnosis && data.diagnosis) {
this.$emit('update:diagnosis', data.diagnosis)
}
if (!this.medicalAdvice && data.doctor_order) {
this.$emit('update:medicalAdvice', data.doctor_order)
}
this.localDiagnosis = this.diagnosis || data.diagnosis || ''
this.localAdvice = this.medicalAdvice || data.doctor_order || ''
}
if (this.diagnosis) this.localDiagnosis = this.diagnosis
if (this.medicalAdvice) this.localAdvice = this.medicalAdvice
this.$emit('vip-ok')
} catch (e) {
const draft = PrescriptionStorage.loadMedicalRecordDraft(this.registerId)
if (draft) {
this.applyDraft(draft)
this.$emit('vip-ok')
} else {
const msg = (e && e.message) || (e && e.msg) || ''
if (String(msg).indexOf('VIP') > -1 || String(msg).indexOf('vip') > -1 || String(msg).indexOf('病历') > -1) {
this.$emit('vip-denied')
}
}
} finally {
this.hydrating = false
}
},
getPayload() {
return Object.assign({}, this.form, {
register_id: Number(this.registerId),
store_id: Number(this.storeId || 0),
user_patient_id: this.effectivePatientId,
diagnosis: this.localDiagnosis,
doctor_order: this.localAdvice,
medicalAdvice: this.localAdvice,
})
},
async onSave() {
await saveMedicalRecordApi(this.getPayload())
this.persistDraft()
uni.showToast({ title: '病历已保存', icon: 'success' })
},
async onClear() {
await clearMedicalRecordApi({
register_id: this.registerId,
store_id: this.storeId || undefined,
})
const keepPatient = this.effectivePatientId
this.form = emptyForm()
this.form.user_patient_id = keepPatient
this.localDiagnosis = ''
this.localAdvice = ''
this.$emit('update:diagnosis', '')
this.$emit('update:medicalAdvice', '')
PrescriptionStorage.clearMedicalRecordDraft(this.registerId)
uni.showToast({ title: '已清空', icon: 'none' })
},
async onCite() {
const patientId = this.effectivePatientId
if (!patientId) {
uni.showToast({ title: '无就诊人', icon: 'none' })
return
}
const res = await latestMedicalRecordApi({
user_patient_id: patientId,
register_id: this.registerId,
store_id: this.storeId || undefined,
})
const data = (res && res.data) || res
if (!data) {
uni.showToast({ title: '暂无历史病历', icon: 'none' })
return
}
this.form = Object.assign(emptyForm(), data, {
user_patient_id: patientId,
})
if (data.diagnosis) {
this.localDiagnosis = data.diagnosis
this.$emit('update:diagnosis', data.diagnosis)
}
if (data.doctor_order) {
this.localAdvice = data.doctor_order
this.$emit('update:medicalAdvice', data.doctor_order)
}
this.persistDraft()
uni.showToast({ title: '已引用', icon: 'success' })
},
async openEntry(fieldCode) {
// 诊断/医嘱不走病历词条
if (fieldCode === 'diagnosis' || fieldCode === 'doctor_order') {
return
}
this.entryField = fieldCode
this.entryTcmType = ''
this.entryKeyword = ''
this.entryShow = true
await this.loadEntryList()
},
/**
* 打开中医三典选择(疾病/证候/治法)
*/
async openTcmEntry(item) {
if (!item || !item.tcmType) return
this.entryField = item.code
this.entryTcmType = item.tcmType
this.entryKeyword = ''
this.entryShow = true
await this.loadEntryList()
},
/** 词条关键字输入防抖u-input 的 @input 可能先于 v-model以事件值为准 */
onEntryKeywordInput(val) {
if (typeof val === 'string') {
this.entryKeyword = val
}
if (this.entrySearchTimer) clearTimeout(this.entrySearchTimer)
this.entrySearchTimer = setTimeout(() => {
this.loadEntryList()
}, 500)
},
/**
* 解析病历词条接口列表Laravel jok 在 result兼容 data
*/
unwrapEntryList(res) {
if (Array.isArray(res)) return res
if (!res || typeof res !== 'object') return []
if (Array.isArray(res.result)) return res.result
if (Array.isArray(res.data)) return res.data
return []
},
/**
* 拉取词条或中医字典,并做匹配度排序 + 高亮拆分
*/
async loadEntryList() {
try {
const kw = (this.entryKeyword || '').trim()
let list = []
// 小程序侧统一前端打分,保证有匹配度排序;后端若已打分则 apply 内会优先用
let mode = 'frontend'
if (this.entryTcmType) {
list = await this.fetchTcmDictList(this.entryTcmType, kw)
} else {
const res = await searchMedicalRecordEntryApi({
field_code: this.entryField,
keyword: kw || undefined,
store_id: this.storeId || undefined,
})
list = this.unwrapEntryList(res)
if (list[0] && list[0].rank_mode) {
mode = String(list[0].rank_mode)
}
list = list.map((row) =>
Object.assign({}, row, {
pinyin_initials: formatPyDisplayOf(row),
}),
)
}
const ranked = applyDictRankAndHighlight(
list,
kw,
(row) => String((row.title || '') + ' ' + (row.content || '')),
mode,
(row) => String(row.pinyin_initials || row.pinyin || ''),
)
this.entryList = ranked.map((row) =>
Object.assign({}, row, {
_titleParts: splitHighlight(row.title || '', kw),
_contentParts: splitHighlight(row.content || '', kw),
_pyParts: splitHighlight(row.pinyin_initials || '', kw),
}),
)
} catch (e) {
this.entryList = []
}
},
/**
* 从本地/接口中医术语表过滤出候选列表
*/
async fetchTcmDictList(tcmType, kw) {
const all = await getTraditionalChineseMedicineJson()
const rows = (all && all[tcmType]) || []
const k = (kw || '').toLowerCase()
const result = []
for (let i = 0; i < rows.length; i++) {
const item = rows[i] || {}
const name = String(item.name || '').trim()
if (!name) continue
const py = formatPyDisplayOf(item)
const pyRaw = [
item.pinyin_display,
item.pinyin_initials,
item.initials_of_pinyin,
item.pinyin_full,
]
.map((v) => String(v || '').toLowerCase())
.join(' ')
const alias = String(item.alias || '')
if (
kw &&
name.indexOf(kw) < 0 &&
name.toLowerCase().indexOf(k) < 0 &&
pyRaw.indexOf(k) < 0 &&
alias.indexOf(kw) < 0
) {
continue
}
result.push({
id: item.id || name,
title: name,
content: name,
remark: '中医字典',
pinyin_initials: py,
})
}
return kw ? result : result.slice(0, 80)
},
pickEntry(row) {
const text = row.content || row.title || ''
const field = this.entryField
if (field === 'tongue' || field === 'pulse') {
this.form[field] = text
} else if (
field === 'tcm_disease' ||
field === 'tcm_syndrome' ||
field === 'tcm_method'
) {
this.form[field] = this.appendCommaText(this.form[field] || '', text)
} else {
this.form[field] = this.appendText(this.form[field] || '', text)
}
this.entryShow = false
this.entryTcmType = ''
this.schedulePersistDraft()
},
appendText(base, add) {
if (!add) return base || ''
if (!base) return add
return base + (base.endsWith('\n') ? '' : '\n') + add
},
/** 中文逗号拼接,去重 */
appendCommaText(base, add) {
if (!add) return base || ''
const parts = String(base || '')
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean)
if (parts.indexOf(add) >= 0) return parts.join('')
parts.push(add)
return parts.join('')
},
},
}
</script>
<style scoped>
.mr-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
padding: 0;
background: #fff;
}
.mr-actions {
flex-shrink: 0;
gap: 16rpx;
padding: 20rpx 32rpx;
background: #fff;
border-bottom: 1px solid #eee;
z-index: 2;
}
.mr-scroll {
flex: 1;
height: 0;
min-height: 0;
}
.mr-scroll-inner {
padding: 24rpx 32rpx 48rpx;
}
.mr-btn {
padding: 12rpx 24rpx;
background: #f0f0f0;
border-radius: 8rpx;
font-size: 24rpx;
}
.mr-btn.primary {
background: #6acdbb;
color: #fff;
}
.mr-btn.danger {
background: #ffecec;
color: #e54d42;
}
.mr-field {
margin-bottom: 24rpx;
}
.mr-entry-row {
margin: 8rpx 0;
padding: 12rpx 16rpx;
background: #f7f8fa;
border-radius: 8rpx;
}
.vitals {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.vital-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
}
.entry-drawer {
background: #fff;
max-height: 70vh;
padding: 32rpx;
overflow-y: auto;
}
.entry-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.entry-title {
font-size: 32rpx;
font-weight: bold;
}
.entry-count {
font-size: 24rpx;
color: #666;
font-weight: 600;
}
.entry-row {
padding: 20rpx 0;
border-bottom: 1px solid #eee;
}
.entry-row-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.entry-row-name {
flex: 1;
min-width: 0;
}
.entry-score {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 600;
color: #d4380d;
}
.hit-text {
color: #cf1322;
background: rgba(255, 214, 102, 0.55);
}
</style>

View File

@@ -73,7 +73,16 @@
:class="{ 'is-selected': item.isSelect === 1 }"
@click="selectDiagnosis(item)"
>
<text class="tag-text">{{ item.name }}</text>
<text class="tag-text">
<block
v-for="(p, pi) in (item._hlParts || [{ text: item.name, hit: false }])"
:key="pi"
>
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</text>
<text v-if="searchKey" class="tag-score">{{ item.match_score || 0 }}%</text>
<view class="tag-action" @click.stop="addToMyDiagnosis(item)" v-if="!item.isInMyList">
<u-icon name="plus" size="20"></u-icon>
</view>
@@ -140,6 +149,7 @@
// 保留所有原有逻辑代码与注释
import { getDiseaseList, getMyDiseaseList, addMyDisease, deleteMyDisease } from '@/api/reception.js';
import { getClinicSalespersonDiseaseList } from '@/api/clinicSalesperson.js';
import { applyDictRankAndHighlight, splitHighlight } from '../../utils/dictSearchRank.js';
export default {
name: 'DiagnosisModal',
@@ -228,29 +238,37 @@ export default {
}
let list = [];
let hasMore = false;
let rankMode = 'backend';
// 正确解析响应数据
// 正确解析响应数据(分页结构为 data.list
if (res && (res.code === 0 || res.errcode === 0)) {
// 优先使用 data然后是 result
if (res.data && Array.isArray(res.data)) {
list = res.data;
} else if (res.result && Array.isArray(res.result)) {
list = res.result;
const payload = res.data !== undefined ? res.data : res.result;
if (payload && Array.isArray(payload.list)) {
list = payload.list;
hasMore = !!payload.has_more;
if (payload.rank_mode) rankMode = String(payload.rank_mode);
} else if (Array.isArray(payload)) {
list = payload;
hasMore = list.length >= this.pageSize;
} else if (Array.isArray(res)) {
list = res;
}
// 从响应中获取分页信息
if (res.pagination) {
hasMore = list.length >= this.pageSize;
} else if (res.pagination) {
hasMore = res.pagination.has_more || false;
} else if (res.has_more !== undefined) {
hasMore = res.has_more;
} else {
// 如果没有分页信息,根据返回数量判断
hasMore = list.length >= this.pageSize;
}
}
// 前端模式时本地按匹配度重排;并拆高亮片段
list = applyDictRankAndHighlight(
list,
searchKey,
(item) => String(item.name || ''),
rankMode,
(item) => String(item.pinyin || item.pinyin_initials || ''),
);
const processedList = list.map((item) => {
item.isSelect = 0;
item.isInMyList = this.doctorMyDiseaseList.some(
@@ -264,6 +282,7 @@ export default {
}
});
}
item._hlParts = item._hlParts || splitHighlight(item.name || '', searchKey);
return item;
});
@@ -334,7 +353,11 @@ export default {
* 搜索诊断
* 职责:防抖搜索,只在有输入时调用加载诊断列表
*/
handleSearch() {
handleSearch(val) {
// u-input @input 传入最新值,避免 v-model 滞后导致搜空/不排序
if (typeof val === 'string') {
this.searchKey = val
}
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
if (this.searchKey && this.searchKey.trim()) {
@@ -348,7 +371,7 @@ export default {
this.currentPage = 1;
this.hasMore = false;
}
}, 300);
}, 500);
},
/**
@@ -546,6 +569,13 @@ export default {
color: #4b5563;
}
.tag-score {
margin-left: 8rpx;
font-size: 22rpx;
font-weight: 600;
color: #d4380d;
}
.tag-action {
margin-left: 12rpx;
display: flex;
@@ -554,6 +584,11 @@ export default {
}
}
.hit-text {
color: #cf1322;
background: rgba(255, 214, 102, 0.55);
}
.load-more {
display: flex;
align-items: center;

View File

@@ -15,6 +15,18 @@
</view>
<view class="modal-content">
<view class="search-box">
<u-input
v-model="searchKey"
placeholder="搜索医嘱内容..."
:custom-style="searchStyle"
@input="onSearchInput"
>
<template slot="suffix">
<u-icon name="search" size="32" color="#B0B6C2"></u-icon>
</template>
</u-input>
</view>
<scroll-view class="order-scroll" scroll-y>
<view class="order-scroll-inner">
<!-- 我的医嘱 -->
@@ -30,13 +42,19 @@
<!-- 我的医嘱列表 -->
<view class="modern-tag-container">
<view
v-for="item in doctorOrderMyList"
v-for="item in filteredMyList"
:key="item.id"
class="modern-tag-item"
:class="{ 'is-selected': item.isSelect === 1 }"
@click="selectDoctorOrder(item, 1)"
>
<text class="tag-text">{{ item.content }}</text>
<text class="tag-text">
<block v-for="(p, pi) in item._hlParts" :key="pi">
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</text>
<text v-if="debouncedSearchKey" class="tag-score">{{ item.match_score || 0 }}%</text>
<view class="tag-action" @click.stop="deleteMyDoctorOrder(item)">
<u-icon name="close" size="20"></u-icon>
</view>
@@ -45,19 +63,25 @@
</view>
<!-- 公共医嘱 -->
<view class="section white radius-16 p-32 shadow-sm" v-if="doctorOrderCommonList.length > 0">
<view class="section white radius-16 p-32 shadow-sm" v-if="filteredCommonList.length > 0">
<view class="section-header m-b-24">
<d-text text="系统公共医嘱" className="fs-30 font-bold color-title"></d-text>
</view>
<view class="modern-tag-container">
<view
v-for="item in doctorOrderCommonList"
v-for="item in filteredCommonList"
:key="item.id"
class="modern-tag-item"
:class="{ 'is-selected': item.isSelect === 1 }"
@click="selectDoctorOrder(item, 2)"
>
<text class="tag-text">{{ item.content }}</text>
<text class="tag-text">
<block v-for="(p, pi) in item._hlParts" :key="pi">
<text v-if="p.hit" class="hit-text">{{ p.text }}</text>
<text v-else>{{ p.text }}</text>
</block>
</text>
<text v-if="debouncedSearchKey" class="tag-score">{{ item.match_score || 0 }}%</text>
</view>
</view>
</view>
@@ -124,6 +148,7 @@
<script>
// 保留所有原有逻辑代码与注释
import { getDoctorOrderList, getDoctorOrderCommonList, createDoctorOrder, deleteDoctorOrder } from '@/api/reception.js';
import { applyDictRankAndHighlight, splitHighlight } from '../../utils/dictSearchRank.js';
export default {
name: 'DoctorOrderModal',
@@ -150,14 +175,41 @@ export default {
doctorOrderMyList: [],
selectDoctorOrderList: '',
showAddModal: false,
newOrderContent: ''
newOrderContent: '',
searchKey: '',
/** 防抖后的关键字,用于过滤/排序/高亮 */
debouncedSearchKey: '',
searchTimer: null,
searchStyle: {
backgroundColor: '#fff',
borderRadius: '16rpx',
height: '80rpx',
padding: '0 32rpx',
marginBottom: '24rpx',
boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.02)'
}
};
},
computed: {
/** 我的医嘱:本地过滤 + 匹配度排序 + 高亮 */
filteredMyList() {
return this.filterOrderList(this.doctorOrderMyList, (item) => item.content || '')
},
/** 公共医嘱 */
filteredCommonList() {
return this.filterOrderList(
this.doctorOrderCommonList,
(item) => item.content || item.name || '',
)
},
},
watch: {
value(newVal) {
this.show = newVal;
if (newVal) {
this.selectDoctorOrderList = this.selectedDoctorOrder || '';
this.searchKey = '';
this.debouncedSearchKey = '';
this.loadDoctorOrderListData();
}
},
@@ -168,6 +220,42 @@ export default {
}
},
methods: {
onSearchInput(val) {
// 输入框即时回显,过滤走防抖关键字
if (typeof val === 'string') {
this.searchKey = val
}
if (this.searchTimer) clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => {
this.debouncedSearchKey = this.searchKey || ''
}, 500)
},
/**
* 医嘱列表本地过滤排序(接口无 keyword统一前端打分
*/
filterOrderList(list, getText) {
const kw = (this.debouncedSearchKey || '').trim()
let rows = list || []
if (kw) {
const lower = kw.toLowerCase()
rows = rows.filter((item) => {
const text = String(getText(item) || '')
const py = String(item.pinyin_initials || item.pinyin || '').toLowerCase()
return text.indexOf(kw) > -1 || py.indexOf(lower) > -1
})
}
return applyDictRankAndHighlight(
rows,
kw,
getText,
'frontend',
(item) => String(item.pinyin_initials || item.pinyin || ''),
).map((item) =>
Object.assign({}, item, {
_hlParts: splitHighlight(getText(item), kw),
}),
)
},
/**
* 加载医嘱列表数据
* 职责:调用接口获取我的医嘱和公共医嘱列表
@@ -446,6 +534,13 @@ export default {
color: #4b5563;
}
.tag-score {
margin-left: 8rpx;
font-size: 22rpx;
font-weight: 600;
color: #d4380d;
}
.tag-action {
margin-left: 12rpx;
display: flex;
@@ -462,4 +557,13 @@ export default {
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background-color: #fff;
}
.search-box {
padding: 0 32rpx 8rpx;
}
.hit-text {
color: #cf1322;
background: rgba(255, 214, 102, 0.55);
}
</style>

View File

@@ -99,6 +99,8 @@ export default {
searchTimer: null,
pendingId: null,
scrollTop: 0,
/** 防抖后的过滤关键字(输入框仍用 searchKey 即时回显) */
filterKey: '',
searchStyle: {
fontSize: '28rpx',
backgroundColor: '#fff',
@@ -133,7 +135,7 @@ export default {
return this.title || '请选择';
},
searchKeyTrim() {
return (this.searchKey || '').trim();
return (this.filterKey || '').trim();
},
displayedList() {
const key = this.searchKeyTrim.toLowerCase();
@@ -146,7 +148,7 @@ export default {
if (!it) continue;
const name = (it.name || '').toLowerCase();
const alias = (it.alias || '').toLowerCase();
const py = (it.initials_of_pinyin || '').toLowerCase();
const py = (it.initials_of_pinyin || it.pinyin_initials || it.pinyin_full || '').toLowerCase();
if (name.includes(key) || alias.includes(key) || py.includes(key)) {
out.push(it);
}
@@ -159,6 +161,7 @@ export default {
this.show = newVal;
if (newVal) {
this.searchKey = '';
this.filterKey = '';
this.pendingId = this.selectedId != null && this.selectedId !== '' ? Number(this.selectedId) : null;
this.scrollTop = 0;
}
@@ -170,14 +173,19 @@ export default {
}
},
methods: {
onSearchInput() {
onSearchInput(val) {
if (typeof val === 'string') {
this.searchKey = val;
}
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.filterKey = this.searchKey || '';
this.scrollTop = 0;
}, 80);
}, 500);
},
onSearchClear() {
this.searchKey = '';
this.filterKey = '';
this.scrollTop = 0;
},
handlePick(item) {

View File

@@ -12,8 +12,27 @@
<u-navbar class="navbar rx-flex-fixed" :is-back="true" :title="navbarTitle" :custom-back="boundCustomBack" title-color="#000" background="{ background: '#fff' }">
</u-navbar>
<!-- 外层处方 | 病历VIP -->
<view v-if="canUseMedicalRecord" class="outer-tabs rx-flex-fixed flex-row">
<view
class="outer-tab"
:class="{ 'outer-tab--active': mainTab === 'rx' }"
@click="switchMainTab('rx')"
>
<text>处方</text>
</view>
<view
class="outer-tab"
:class="{ 'outer-tab--active': mainTab === 'mr' }"
@click="switchMainTab('mr')"
>
<text>病历</text>
</view>
</view>
<!-- 以下处方区与 HEAD 一致直接挂在 page-flex 禁止再包一层 flex 会压矮 swiper -->
<!-- 处方类型 Tab与用户端订单列表一致u-tabs + 下方 swiper 双向同步 -->
<view v-if="!isSalespersonTransferMode" class="tabs-wrap shadow-sm rx-flex-fixed">
<view v-show="isRxMainVisible" v-if="!isSalespersonTransferMode" class="tabs-wrap shadow-sm rx-flex-fixed">
<u-tabs
:list="prescriptionTabsList"
:is-scroll="true"
@@ -25,11 +44,12 @@
></u-tabs>
</view>
<view v-if="!isSalespersonTransferMode && registerModeHint" class="register-mode-hint mx-32 m-t-12 rx-flex-fixed">
<view v-show="isRxMainVisible" v-if="!isSalespersonTransferMode && registerModeHint" class="register-mode-hint mx-32 m-t-12 rx-flex-fixed">
<text class="register-mode-hint__text">{{ registerModeHint }}</text>
</view>
<view
v-show="isRxMainVisible"
v-if="!isSalespersonTransferMode && hasSpecialPrescriptionRecord"
class="special-rx-card mx-32 m-t-12 rx-flex-fixed"
>
@@ -81,17 +101,17 @@
</view>
</view>
<view v-if="!isSalespersonTransferMode && allowInsuranceCategory" class="mx-32 m-t-12 flex flex-ali-center rx-flex-fixed">
<view v-show="isRxMainVisible" v-if="!isSalespersonTransferMode && allowInsuranceCategory" class="mx-32 m-t-12 flex flex-ali-center rx-flex-fixed">
<u-button size="mini" :type="feeCategory === 1 ? 'success' : 'default'" @click="feeCategory = 1">自费</u-button>
<u-button size="mini" class="m-l-16" :type="feeCategory === 2 ? 'success' : 'default'" @click="feeCategory = 2">医保</u-button>
</view>
<view v-if="!isSalespersonTransferMode && activeCategory === 1 && hasSalespersonTransfer" class="mx-32 m-t-12 rx-flex-fixed">
<view v-show="isRxMainVisible" v-if="!isSalespersonTransferMode && activeCategory === 1 && hasSalespersonTransfer" class="mx-32 m-t-12 rx-flex-fixed">
<u-button size="mini" type="primary" plain @click="openSalespersonTransfer">查看传方</u-button>
</view>
<!-- 诊断输入区域传方模式不展示 -->
<view v-if="!isSalespersonTransferMode" class="top white p-32 flex-col m-t-16 radius-12 mx-32 rx-flex-fixed">
<!-- 诊断swiper 外固定与改 VIP 前一致线下可手输也可点选在线复诊必须点选 -->
<view v-show="isRxMainVisible" v-if="!isSalespersonTransferMode" class="top white p-32 flex-col m-t-16 radius-12 mx-32 rx-flex-fixed">
<view class="top_title flex-row flex-ali-center">
<image :src="require('@/static/image/js.png')" class="size-32"></image>
<d-text text="临床诊断" className="fs-32 m-l-16 font-bold" color="#333"></d-text>
@@ -109,17 +129,24 @@
@close="handleRemoveDiagnosis(i)" />
</view>
</view>
<view class="m-t-24">
<view class="m-t-24 flex-row flex-ali-center">
<u-input
disabled
placeholder="请选择或输入疾病诊断"
class="flex-1"
:disabled="isOnlineRevisit"
:value="diagnosisText"
:placeholder="isOnlineRevisit ? '请选择疾病诊断' : '请选择或输入疾病诊断'"
:custom-style="illnessStyle"
@click="handleOpenDiagnosisModal" />
@input="onDiagnosisTextInput"
@click="onDiagnosisInputClick" />
<view class="m-l-16 diagnosis-pick-btn" @click="handleOpenDiagnosisModal">
<d-text text="选择" className="fs-26" color="#6ACDBB"></d-text>
</view>
</view>
</view>
<!-- 滚动内容swiper flex:1 吃满剩余高度对齐订单列表 order-swiper不算死高度 -->
<!-- 滚动内容swiper 必须是 page-flex 的直接子项flex:1 + height:0 才能吃满剩余高度 -->
<swiper
v-show="isRxMainVisible"
class="rx-order-swiper"
:current="swiperCurrent"
:disable-touch="isSalespersonTransferMode || swiperCategories.length < 2"
@@ -494,8 +521,8 @@
</swiper-item>
</swiper>
<!-- 底部操作栏 -->
<view class="bottom flex-jus-sp flex-ali-center safe-area-inset-bottom">
<!-- 底部操作栏fixed不占 flex须与 swiper 同为 page-flex 直接子项 -->
<view v-show="isRxMainVisible" class="bottom flex-jus-sp flex-ali-center safe-area-inset-bottom">
<view
v-if="!isSalespersonTransferMode && !isCommonPrescription && supportsCommonPrescriptionTemplate"
class="flex-row flex-ali-center"
@@ -517,6 +544,34 @@
</u-button>
</view>
<!--
病历常挂载保草稿 / 探测 VIP
处方 Tabfixed 离屏探测绝不参与 page-flex 占高
病历 Tabflex:1 吃满剩余高度
-->
<view
class="mr-host"
:class="isMrMainVisible ? 'mr-host--fill' : 'mr-host--probe'"
>
<view class="mr-panel-fill">
<MedicalRecordPanel
ref="medicalRecordPanel"
:register-id="registerId"
:store-id="currentStoreId"
:user-patient-id="patientUserPatientId"
:diagnosis="diagnosisText"
:medical-advice="medicalAdvice"
:diagnosis-readonly="isOnlineRevisit"
@update:diagnosis="onMrDiagnosisUpdate"
@update:medicalAdvice="onMrAdviceUpdate"
@open-diagnosis="handleOpenDiagnosisModal"
@open-doctor-order="handleOpenDoctorOrderModal"
@vip-ok="onMedicalRecordVipOk"
@vip-denied="onMedicalRecordVipDenied"
/>
</view>
</view>
<TraditionalTermPickerModal
v-model="showTcmPicker"
:title="tcmPickerTitle"
@@ -655,6 +710,8 @@ import { PrescriptionCalculator } from './utils/prescriptionCalculator.js';
import { PrescriptionValidator } from './utils/prescriptionValidator.js';
import DiagnosisModal from './components/modals/DiagnosisModal.vue';
import DoctorOrderModal from './components/modals/DoctorOrderModal.vue';
import MedicalRecordPanel from './components/MedicalRecordPanel.vue';
import { saveMedicalRecordApi } from '@/api/reception.js';
import CommonPrescriptionModal from './components/modals/CommonPrescriptionModal.vue';
import WesternMedicineModal from './components/modals/WesternMedicineModal.vue';
import ChineseMedicineModal from './components/modals/ChineseMedicineModal.vue';
@@ -704,6 +761,7 @@ export default {
components: {
DiagnosisModal,
DoctorOrderModal,
MedicalRecordPanel,
CommonPrescriptionModal,
WesternMedicineModal,
ChineseMedicineModal,
@@ -719,6 +777,10 @@ export default {
return {
registerId: '',
patientId: '',
/** 外层处方|病历 */
mainTab: 'rx',
/** 是否开通病历 VIP拉取病历成功后为 true */
canUseMedicalRecord: false,
pendingReusePrescriptionId: '',
patientInfo: null,
prescriptionCategories: [
@@ -880,6 +942,14 @@ export default {
}
return this.visiblePrescriptionCategories;
},
/** 处方主界面可见:无 VIP 或当前在处方 Tab病历 Tab 时整段隐藏,把高度让给病历) */
isRxMainVisible() {
return !this.canUseMedicalRecord || this.mainTab === 'rx';
},
/** 病历主界面VIP 且当前在病历 Tab */
isMrMainVisible() {
return this.canUseMedicalRecord && this.mainTab === 'mr';
},
/** u-tabs 需要 { name };有 icon_text 时拼到 name 后便于看见角标;始终返回数组避免 list 非数组警告 */
prescriptionTabsList() {
const list = this.visiblePrescriptionCategories || [];
@@ -915,6 +985,20 @@ export default {
|| uni.getStorageSync('store_id')
|| null;
},
/** 病历用门店 ID */
currentStoreId() {
return Number(
this.selectedStoreId
|| (this.registerStoreInfo && this.registerStoreInfo.store_id)
|| uni.getStorageSync('store_id')
|| 0
);
},
/** 病历用就诊人 ID */
patientUserPatientId() {
const p = this.patientInfo || {};
return Number(p.id || p.user_patient_id || this.patientId || 0);
},
totalProductCost() {
if (this.currentDrugs.length === 0) return 0;
if (this.activeCategory === 1) {
@@ -1647,6 +1731,95 @@ export default {
handleOpenDiagnosisModal() {
this.showDiagnosisModal = true;
},
/**
* 切换外层处方|病历 Tab并写入本地
* @param {'rx'|'mr'} tab
*/
switchMainTab(tab) {
if (tab === 'mr' && !this.canUseMedicalRecord) {
uni.showToast({ title: '未开通病历功能', icon: 'none' });
return;
}
this.mainTab = tab;
if (this.registerId) {
PrescriptionStorage.saveRxMrTab(tab, this.registerId);
}
},
/** 从本地恢复外层 Tab */
restoreMainTab() {
if (!this.registerId || !this.canUseMedicalRecord) {
this.mainTab = 'rx';
return;
}
const saved = PrescriptionStorage.loadRxMrTab(this.registerId);
this.mainTab = saved || 'rx';
},
/**
* 线下:输入框可编辑,按中文/英文逗号拆成诊断标签
* 在线复诊:输入框 disabled不走此逻辑
*/
onDiagnosisTextInput(val) {
if (this.isOnlineRevisit) return;
const text = typeof val === 'string' ? val : (val && val.detail && val.detail.value) || '';
this.diagnosisText = text;
const names = String(text)
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
this.diagnoses = names.map((name, index) => ({ id: `typed_${index}`, name, fromSelect: false }));
this.saveToLocalStorage();
},
/** 在线复诊点击输入框时打开常用诊断;线下不拦截手输 */
onDiagnosisInputClick() {
if (this.isOnlineRevisit) {
this.handleOpenDiagnosisModal();
}
},
onMrDiagnosisUpdate(v) {
this.diagnosisText = v || '';
if (this.diagnosisText) {
const names = String(this.diagnosisText).split(/[,]/).map((s) => s.trim()).filter(Boolean);
// 病历引用回填不算「已选择」,在线复诊仍需点常用诊断确认
this.diagnoses = names.map((name, index) => ({
id: `mr_${index}`,
name,
fromSelect: false,
}));
} else {
this.diagnoses = [];
}
},
onMrAdviceUpdate(v) {
this.medicalAdvice = v || '';
},
onMedicalRecordVipDenied() {
this.canUseMedicalRecord = false;
if (this.mainTab === 'mr') this.mainTab = 'rx';
},
onMedicalRecordVipOk() {
this.canUseMedicalRecord = true;
this.restoreMainTab();
},
/** 发送处方成功后同步病历(有 VIP 时) */
async syncMedicalRecordAfterSend() {
if (!this.canUseMedicalRecord) return;
try {
const panel = this.$refs.medicalRecordPanel;
const payload = panel && panel.getPayload
? panel.getPayload()
: {
diagnosis: this.diagnosisText,
doctor_order: this.medicalAdvice,
medicalAdvice: this.medicalAdvice,
register_id: Number(this.registerId),
store_id: this.currentStoreId,
user_patient_id: this.patientUserPatientId,
};
await saveMedicalRecordApi(payload);
} catch (e) {
console.error('同步保存病历失败', e);
}
},
async ensureTraditionalTcmData() {
if (this.traditionalTcmData) return this.traditionalTcmData;
if (this.traditionalTcmLoadPromise) return this.traditionalTcmLoadPromise;
@@ -1689,7 +1862,11 @@ export default {
this.diagnosisText = diagnosisText;
if (diagnosisText) {
const diagnosisNames = diagnosisText.split('').filter(Boolean);
this.diagnoses = diagnosisNames.map((name, index) => ({ id: `temp_${index}`, name: name }));
this.diagnoses = diagnosisNames.map((name, index) => ({
id: `temp_${index}`,
name: name,
fromSelect: true,
}));
} else {
this.diagnoses = [];
}
@@ -2303,6 +2480,7 @@ export default {
category: this.activeCategory,
drugs: this.currentDrugs,
diagnoses: this.diagnoses,
diagnosisText: this.diagnosisText,
medicalAdvice: this.medicalAdvice,
chineseConfig: this.activeCategory === 1 ? this.chineseConfig : null,
requireOnlineTcm: this.activeCategory === 1 && this.isOnlineRevisit,
@@ -2310,6 +2488,7 @@ export default {
onlineTcmMethodId: this.onlineTcmMethodId,
onlineTcmSyndromeId: this.onlineTcmSyndromeId,
skipDiagnosisAndAdvice: this.isSalespersonTransferMode,
isOnlineRevisit: this.isOnlineRevisit,
});
},
async checkChineseMedicineConflict() {
@@ -2570,6 +2749,7 @@ export default {
const res = await addWestPrescription(params);
if (res && (res.code === 0 || res.errcode === 0)) {
this.$toast('发送成功');
await this.syncMedicalRecordAfterSend();
const needTransfer = res?.result?.need_transfer || res?.data?.need_transfer || res?.need_transfer;
if (needTransfer) {
const transferId = res?.result?.transfer_prescription_id || res?.data?.transfer_prescription_id || res?.transfer_prescription_id;
@@ -2868,7 +3048,7 @@ export default {
}
/* 整页纵向 flex顶部 chrome 不收缩swiper 吃满剩余高度 */
.page-flex {
height: 100vh;
height: 110vh;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -2877,6 +3057,57 @@ export default {
.rx-flex-fixed {
flex-shrink: 0;
}
.outer-tabs {
background: #fff;
padding: 0 24rpx;
border-bottom: 1px solid #eee;
}
.outer-tab {
padding: 20rpx 32rpx;
font-size: 28rpx;
color: #606266;
}
.outer-tab--active {
color: #6acdbb;
font-weight: bold;
border-bottom: 4rpx solid #6acdbb;
}
/**
* 病历宿主:
* - fill病历 Tab作为 page-flex 子项 flex:1 吃满
* - probe处方 Tab / 未开通fixed 离屏探测 VIP不占 flex 高度(这是之前高度被压矮的根因)
*/
.mr-host--fill {
flex: 1 1 0%;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
}
.mr-host--probe {
position: fixed !important;
left: -200vw !important;
top: 0 !important;
width: 2px !important;
height: 2px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
z-index: -1 !important;
flex: 0 0 auto !important;
max-height: 0 !important;
visibility: hidden !important;
}
.mr-panel-fill {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
.safe-area-inset-bottom { padding-bottom: env(safe-area-inset-bottom); }
.mx-32 { margin-left: 32rpx; margin-right: 32rpx; }
.m-x-32 { margin-left: 32rpx; margin-right: 32rpx; }
@@ -3096,6 +3327,12 @@ export default {
border-radius: 12rpx;
border: 1rpx solid #d4efe8;
}
.diagnosis-pick-btn {
flex-shrink: 0;
padding: 12rpx 20rpx;
background: #e8f6f4;
border-radius: 12rpx;
}
.register-mode-hint__text {
font-size: 24rpx;
color: #008771;

View File

@@ -0,0 +1,116 @@
/**
* 字典搜索匹配度 / 高亮(与 PC dictSearchRank、后端 DictSearchRankService 对齐)
* 小程序侧:按 rank_mode 决定是否本地重排;高亮始终本地拆分(禁止 v-html
*/
/**
* @param {string} text
* @param {string} keyword
* @param {string} [extra]
* @returns {number}
*/
export function scoreDictMatch(text, keyword, extra) {
const kw = String(keyword || '').trim()
if (!kw) return 0
const main = String(text || '').trim()
if (!main) return 0
let s = scoreOne(main, kw)
if (extra) {
s = Math.max(s, Math.floor(scoreOne(String(extra), kw) * 0.9))
}
return s
}
function scoreOne(haystack, needle) {
if (haystack === needle) return 100
const lowerH = haystack.toLowerCase()
const lowerN = needle.toLowerCase()
const pos = lowerH.indexOf(lowerN)
if (pos < 0) return 0
if (pos === 0) {
const lenBonus = Math.max(0, 10 - Math.abs(haystack.length - needle.length))
return 80 + Math.min(15, lenBonus)
}
return Math.max(20, 60 - Math.min(40, pos))
}
/**
* @template T
* @param {T[]} items
* @param {string} keyword
* @param {(item: T) => string} getText
* @param {(item: T) => string} [getExtra]
*/
export function rankByMatchScore(items, keyword, getText, getExtra) {
const kw = String(keyword || '').trim()
const scored = (items || []).map((item) => {
const match_score = kw
? scoreDictMatch(getText(item), kw, getExtra ? getExtra(item) : '')
: 0
return Object.assign({}, item, { match_score })
})
if (!kw) return scored
return scored.sort((a, b) => b.match_score - a.match_score)
}
/**
* @param {string} text
* @param {string} keyword
* @returns {{ text: string, hit: boolean }[]}
*/
export function splitHighlight(text, keyword) {
const raw = String(text == null ? '' : text)
const kw = String(keyword || '').trim()
if (!raw || !kw) return [{ text: raw, hit: false }]
const lower = raw.toLowerCase()
const needle = kw.toLowerCase()
const parts = []
let start = 0
let idx = lower.indexOf(needle, start)
while (idx >= 0) {
if (idx > start) {
parts.push({ text: raw.slice(start, idx), hit: false })
}
parts.push({ text: raw.slice(idx, idx + kw.length), hit: true })
start = idx + kw.length
idx = lower.indexOf(needle, start)
}
if (start < raw.length) {
parts.push({ text: raw.slice(start), hit: false })
}
return parts.length ? parts : [{ text: raw, hit: false }]
}
/**
* 按接口 rank_mode 决定是否前端重排;有关键字时保证匹配度降序并可展示分数
* @param {any[]} items
* @param {string} keyword
* @param {(item: any) => string} getText
* @param {string} [rankMode]
* @param {(item: any) => string} [getExtra] 拼音等附加匹配字段
*/
export function applyDictRankAndHighlight(items, keyword, getText, rankMode, getExtra) {
const mode = rankMode === 'frontend' ? 'frontend' : 'backend'
const kw = String(keyword || '').trim()
let list = items || []
if (mode === 'frontend') {
list = rankByMatchScore(list, keyword, getText, getExtra)
} else if (kw) {
const mapped = list.map((item) =>
Object.assign({}, item, { match_score: Number(item.match_score || 0) }),
)
const hasPositive = mapped.some((i) => Number(i.match_score) > 0)
if (!hasPositive) {
list = rankByMatchScore(list, keyword, getText, getExtra)
} else {
list = mapped.sort((a, b) => b.match_score - a.match_score)
}
}
return list.map((item) => {
const text = getText(item) || ''
return Object.assign({}, item, {
match_score: Number(item.match_score || 0),
_hlParts: splitHighlight(text, kw),
})
})
}

View File

@@ -88,6 +88,8 @@ export class PrescriptionStorage {
});
// 清除激活分类
uni.removeStorageSync(this.getActiveCategoryKey(registerId));
uni.removeStorageSync(this.getRxMrTabKey(registerId));
this.clearMedicalRecordDraft(registerId);
} catch (error) {
console.error('清除所有处方数据失败:', error);
}
@@ -141,4 +143,90 @@ export class PrescriptionStorage {
return data && Array.isArray(data.drugs) && data.drugs.length > 0;
});
}
/** 处方|病历 Tab 存储键 */
static getRxMrTabKey(registerId) {
return `${this.STORAGE_PREFIX}rxMrTab_${registerId}`;
}
/**
* 保存外层处方|病历 Tab
* @param {'rx'|'mr'} tab
* @param {string|number} registerId
*/
static saveRxMrTab(tab, registerId) {
if (!registerId) return;
try {
uni.setStorageSync(this.getRxMrTabKey(registerId), tab);
} catch (e) {
console.error('保存处方病历Tab失败', e);
}
}
/**
* 读取外层处方|病历 Tab
* @param {string|number} registerId
* @returns {'rx'|'mr'|null}
*/
static loadRxMrTab(registerId) {
if (!registerId) return null;
try {
const v = uni.getStorageSync(this.getRxMrTabKey(registerId));
if (v === 'rx' || v === 'mr') return v;
return null;
} catch (e) {
return null;
}
}
/** 病历草稿存储键 */
static getMedicalRecordKey(registerId) {
return `${this.STORAGE_PREFIX}medicalRecord_${registerId}`;
}
/**
* 保存病历草稿到本地(刷新不丢)
* @param {string|number} registerId
* @param {Object} data
*/
static saveMedicalRecordDraft(registerId, data) {
if (!registerId) return;
try {
uni.setStorageSync(
this.getMedicalRecordKey(registerId),
JSON.stringify({ ...data, _savedAt: Date.now() }),
);
} catch (e) {
console.error('保存病历草稿失败', e);
}
}
/**
* 读取病历草稿
* @param {string|number} registerId
* @returns {Object|null}
*/
static loadMedicalRecordDraft(registerId) {
if (!registerId) return null;
try {
const raw = uni.getStorageSync(this.getMedicalRecordKey(registerId));
if (!raw) return null;
return typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (e) {
return null;
}
}
/**
* 清除病历草稿
* @param {string|number} registerId
*/
static clearMedicalRecordDraft(registerId) {
if (!registerId) return;
try {
uni.removeStorageSync(this.getMedicalRecordKey(registerId));
} catch (e) {
console.error('清除病历草稿失败', e);
}
}
}

View File

@@ -18,6 +18,7 @@ export class PrescriptionValidator {
category,
drugs,
diagnoses,
diagnosisText,
medicalAdvice,
chineseConfig,
requireOnlineTcm,
@@ -25,6 +26,7 @@ export class PrescriptionValidator {
onlineTcmMethodId,
onlineTcmSyndromeId,
skipDiagnosisAndAdvice,
isOnlineRevisit,
} = params;
// 验证药品
@@ -37,11 +39,31 @@ export class PrescriptionValidator {
// 传方模式跳过诊断与医嘱校验
if (!skipDiagnosisAndAdvice) {
if (!diagnoses || diagnoses.length === 0) {
return {
valid: false,
message: '请添加诊断'
};
const hasDiagnoses = diagnoses && diagnoses.length > 0;
const hasDiagnosisText = diagnosisText && String(diagnosisText).trim() !== '';
if (isOnlineRevisit) {
// 在线复诊必须从常用诊断弹窗选择fromSelect=true
if (!hasDiagnoses) {
return {
valid: false,
message: '请选择诊断'
};
}
const allSelected = diagnoses.every((d) => d && d.fromSelect === true);
if (!allSelected) {
return {
valid: false,
message: '在线复诊请从常用诊断中选择疾病诊断'
};
}
} else {
// 线下:手输或选择均可
if (!hasDiagnoses && !hasDiagnosisText) {
return {
valid: false,
message: '请填写或选择诊断'
};
}
}
if (!medicalAdvice || medicalAdvice.trim() === '') {
return {

View File

@@ -1,6 +1,7 @@
/** 中医术语表本地缓存(与 doctor-reception-wx traditional-chinese-medicine-all 配合) */
export const STORAGE_KEY = 'xk_traditional_tcm_v1';
/** v2条目含 pinyin_full / pinyin_initials 展示串 */
export const STORAGE_KEY = 'xk_traditional_tcm_v2';
/** 默认 3 天(秒),与后端 TRADITIONAL_TCM_CACHE_TTL_SECONDS 默认一致 */
export const DEFAULT_TTL_MS = 259200 * 1000;