feat: 病历模块优化、AI辅助问诊、接入deepseek
This commit is contained in:
@@ -557,7 +557,25 @@ export function aiGenerateMedicalRecordApi(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/** AI 给处方(按病历+处方类型生成,含药名对照) */
|
||||
/** AI 功能门闸:醒目提示 + 是否已签署知情同意 */
|
||||
export function aiFeatureGateApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/ai-feature-gate',
|
||||
method: 'GET',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** AI 知情同意签署(按 VIP 功能码,签署一次即可) */
|
||||
export function aiAgreeConsentApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/ai-agree-consent',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** AI 给处方(支持 use_entrusted_process / process_rule_id 等扩展字段) */
|
||||
export function aiGeneratePrescriptionApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/ai-generate-prescription',
|
||||
@@ -592,3 +610,30 @@ export function aiMatchPrescriptionDrugsApi(data) {
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 金方列表(VIP:golden_formula) */
|
||||
export function goldenFormulaListApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/golden-formula-list',
|
||||
method: 'GET',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 金方详情 */
|
||||
export function goldenFormulaDetailApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/golden-formula-detail',
|
||||
method: 'GET',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 金方导入药名对照 */
|
||||
export function goldenFormulaApplyApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/golden-formula-apply',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
300
components/ai-generation-loading/ai-generation-loading.vue
Normal file
300
components/ai-generation-loading/ai-generation-loading.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 生成专属 Loading(对齐 PC PrescriptionLoading 视觉)
|
||||
仅用于真正调用 AI 生成接口时;历史加载请用普通文案/u-loading
|
||||
type: prescription=药丸 / medical_record=病历本
|
||||
-->
|
||||
<view class="ai-load">
|
||||
<view class="ai-load__stage">
|
||||
<view class="ai-load__ring" />
|
||||
<view class="ai-load__arc" />
|
||||
<view class="ai-load__cross ai-load__cross--1" />
|
||||
<view class="ai-load__cross ai-load__cross--2" />
|
||||
<view class="ai-load__cross ai-load__cross--3" />
|
||||
<!-- 中心图标:处方药丸 -->
|
||||
<view v-if="type !== 'medical_record'" class="ai-load__pill">
|
||||
<view class="ai-load__pill-left" />
|
||||
<view class="ai-load__pill-right" />
|
||||
</view>
|
||||
<!-- 中心图标:病历本 -->
|
||||
<view v-else class="ai-load__mr">
|
||||
<view class="ai-load__mr-spine" />
|
||||
<view class="ai-load__mr-title" />
|
||||
<view class="ai-load__mr-line" />
|
||||
<view class="ai-load__mr-line" />
|
||||
<view class="ai-load__mr-line" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="ai-load__text-row">
|
||||
<text class="ai-load__text">{{ displayText }}</text>
|
||||
<view class="ai-load__dots">
|
||||
<view class="ai-load__dot ai-load__dot--1" />
|
||||
<view class="ai-load__dot ai-load__dot--2" />
|
||||
<view class="ai-load__dot ai-load__dot--3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* AI 生成 Loading
|
||||
* 用 CSS 动画贴近 PC 端 canvas 效果,避免小程序 canvas 生命周期问题
|
||||
*/
|
||||
export default {
|
||||
name: 'AiGenerationLoading',
|
||||
props: {
|
||||
/** prescription | medical_record */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'prescription',
|
||||
},
|
||||
/** 底部文案;空则按 type 给默认 */
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
displayText() {
|
||||
const t = String(this.text || '').trim()
|
||||
if (t) return t
|
||||
return this.type === 'medical_record' ? 'AI正在生成病历' : 'AI正在生成处方'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-load {
|
||||
width: 100%;
|
||||
min-height: 420rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24rpx 0 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-load__stage {
|
||||
position: relative;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
}
|
||||
.ai-load__ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
margin-left: -110rpx;
|
||||
margin-top: -110rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx dashed rgba(180, 195, 200, 0.55);
|
||||
box-sizing: border-box;
|
||||
animation: ai-load-spin 12s linear infinite;
|
||||
}
|
||||
.ai-load__arc {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
margin-left: -110rpx;
|
||||
margin-top: -110rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid transparent;
|
||||
border-top-color: #3dd6d0;
|
||||
border-right-color: #3dd6d0;
|
||||
box-sizing: border-box;
|
||||
animation: ai-load-spin 1.4s linear infinite;
|
||||
}
|
||||
.ai-load__cross {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
margin-top: -8rpx;
|
||||
}
|
||||
.ai-load__cross::before,
|
||||
.ai-load__cross::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
background: #3dd6d0;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
.ai-load__cross::before {
|
||||
width: 16rpx;
|
||||
height: 4rpx;
|
||||
margin-left: -8rpx;
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
.ai-load__cross::after {
|
||||
width: 4rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -2rpx;
|
||||
margin-top: -8rpx;
|
||||
}
|
||||
.ai-load__cross--1 {
|
||||
animation: ai-load-orbit 3.2s linear infinite;
|
||||
}
|
||||
.ai-load__cross--2 {
|
||||
animation: ai-load-orbit 3.2s linear infinite;
|
||||
animation-delay: -1.06s;
|
||||
}
|
||||
.ai-load__cross--3 {
|
||||
animation: ai-load-orbit 3.2s linear infinite;
|
||||
animation-delay: -2.13s;
|
||||
}
|
||||
.ai-load__pill {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 140rpx;
|
||||
height: 48rpx;
|
||||
margin-left: -70rpx;
|
||||
margin-top: -24rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.12);
|
||||
animation: ai-load-float 2.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-load__pill-left {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fdfbf7, #ede8df);
|
||||
}
|
||||
.ai-load__pill-right {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #5dd9d1, #2cb8b0);
|
||||
}
|
||||
.ai-load__mr {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 100rpx;
|
||||
height: 120rpx;
|
||||
margin-left: -50rpx;
|
||||
margin-top: -60rpx;
|
||||
background: #fff;
|
||||
border-radius: 10rpx;
|
||||
border: 2rpx solid rgba(180, 200, 200, 0.55);
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
padding: 18rpx 12rpx 12rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: ai-load-float 2.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-load__mr-spine {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6rpx;
|
||||
bottom: 6rpx;
|
||||
width: 16rpx;
|
||||
background: #3dd6d0;
|
||||
border-radius: 0 4rpx 4rpx 0;
|
||||
}
|
||||
.ai-load__mr-title {
|
||||
width: 60%;
|
||||
height: 12rpx;
|
||||
border-radius: 4rpx;
|
||||
background: rgba(61, 214, 208, 0.35);
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
.ai-load__mr-line {
|
||||
width: 100%;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background: rgba(61, 214, 208, 0.3);
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.ai-load__text-row {
|
||||
margin-top: 28rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ai-load__text {
|
||||
font-size: 28rpx;
|
||||
color: #4a5568;
|
||||
animation: ai-load-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ai-load__dots {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
.ai-load__dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #3dd6d0;
|
||||
margin-left: 8rpx;
|
||||
opacity: 0.25;
|
||||
}
|
||||
.ai-load__dot--1 {
|
||||
animation: ai-load-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ai-load__dot--2 {
|
||||
animation: ai-load-dot 1.4s ease-in-out infinite;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.ai-load__dot--3 {
|
||||
animation: ai-load-dot 1.4s ease-in-out infinite;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes ai-load-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes ai-load-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10rpx);
|
||||
}
|
||||
}
|
||||
@keyframes ai-load-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes ai-load-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
@keyframes ai-load-orbit {
|
||||
from {
|
||||
transform: rotate(0deg) translateX(110rpx) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg) translateX(110rpx) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"^u-(.*)": "uview-ui/components/u-$1/u-$1.vue",
|
||||
"^d-(.*)": "@/components/d-$1/d-$1.vue"
|
||||
"^d-(.*)": "@/components/d-$1/d-$1.vue",
|
||||
"^ai-generation-loading$": "@/components/ai-generation-loading/ai-generation-loading.vue"
|
||||
},
|
||||
"pages": [{
|
||||
"path": "pages/login/index",
|
||||
|
||||
@@ -19,7 +19,19 @@
|
||||
<text class="ai-mr-title">AI写病历</text>
|
||||
<text class="ai-mr-sub">{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || 0 }}岁</text>
|
||||
</view>
|
||||
<view v-if="!genModalVisible" class="ai-mr-body flex-row">
|
||||
<!-- 醒目提示:记录与模态框顶部 -->
|
||||
<view v-if="disclaimerText && !consentVisible" class="ai-disclaimer">
|
||||
<text class="ai-disclaimer-title">重要提示</text>
|
||||
<text class="ai-disclaimer-body">{{ disclaimerText }}</text>
|
||||
</view>
|
||||
<!-- 首次知情同意(抽屉内覆盖层,按 VIP 功能签署一次) -->
|
||||
<view v-if="consentVisible" class="ai-consent-panel">
|
||||
<text class="fs-30 font-bold">AI 使用知情同意</text>
|
||||
<scroll-view scroll-y class="ai-consent-scroll">
|
||||
<text class="ai-consent-body">{{ consentText }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-else-if="!genModalVisible" class="ai-mr-body flex-row">
|
||||
<scroll-view scroll-y class="ai-mr-hist">
|
||||
<view class="ai-hist-hd">
|
||||
<text class="fs-24 font-bold">历史</text>
|
||||
@@ -39,16 +51,29 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-y class="ai-mr-preview">
|
||||
<view v-if="generating || detailLoading" class="p-32 color-sub">AI 处理中…</view>
|
||||
<!-- AI 专属 Loading:仅生成中;历史详情用普通文案 -->
|
||||
<ai-generation-loading
|
||||
v-if="generating"
|
||||
type="medical_record"
|
||||
text="AI正在生成病历"
|
||||
/>
|
||||
<view v-else-if="detailLoading" class="p-32 color-sub fs-24">加载历史病历…</view>
|
||||
<block v-else>
|
||||
<view v-if="!previewChief && !previewRows.length" class="p-32 color-sub fs-24">
|
||||
<view v-if="!previewChief && !previewRows.length && !lastDurationMs" class="p-32 color-sub fs-24">
|
||||
请选择历史或生成病历
|
||||
</view>
|
||||
<view v-else-if="!previewChief && !previewRows.length && lastDurationMs" class="p-32 color-sub fs-24">
|
||||
本次生成未产出病历内容(耗时 {{ durationText }})
|
||||
</view>
|
||||
<block v-else>
|
||||
<view v-if="previewChief" class="ai-chief-box">
|
||||
<text class="fs-20 color-sub">主诉</text>
|
||||
<text class="fs-24 m-t-4">{{ previewChief }}</text>
|
||||
</view>
|
||||
<view v-if="lastDurationMs" class="ai-chief-box">
|
||||
<text class="fs-20 color-sub">生成耗时</text>
|
||||
<text class="fs-24 m-t-4">{{ durationText }}</text>
|
||||
</view>
|
||||
<view v-if="previewRows.length" class="ai-preview-grid">
|
||||
<view
|
||||
v-for="row in previewRows"
|
||||
@@ -70,29 +95,73 @@
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-else class="ai-gen-panel">
|
||||
<view v-else-if="genModalVisible" class="ai-gen-panel">
|
||||
<view class="ai-gen-hd flex-row flex-jus-sp flex-ali-center">
|
||||
<text class="fs-30 font-bold">生成病历</text>
|
||||
<text class="ai-mr-sub" @click="closeGenModal">返回</text>
|
||||
</view>
|
||||
<text class="ai-gen-tip">确认主诉后开始生成,将返回主层等待结果</text>
|
||||
<view class="ai-gen-patient">
|
||||
<text class="fs-24 color-sub">患者</text>
|
||||
<text class="fs-26 m-t-8">{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || 0 }}岁</text>
|
||||
</view>
|
||||
<view class="ai-gen-field">
|
||||
<text class="fs-24 color-sub">主诉(可编辑)</text>
|
||||
<textarea
|
||||
class="ai-gen-textarea"
|
||||
:value="aiChief"
|
||||
maxlength="500"
|
||||
placeholder="请确认或补充主诉"
|
||||
@input="onAiChiefInput"
|
||||
/>
|
||||
</view>
|
||||
<text class="ai-gen-tip">主诉必填;下方选填有内容会作为 AI 参考 · 点击文字可编辑</text>
|
||||
<scroll-view scroll-y class="ai-gen-scroll">
|
||||
<view class="ai-gen-patient">
|
||||
<text class="fs-20 color-sub">患者</text>
|
||||
<text class="fs-24 m-t-4">{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || 0 }}岁</text>
|
||||
</view>
|
||||
<view class="ai-gen-grid">
|
||||
<view class="ai-gen-row ai-gen-row--full">
|
||||
<text class="ai-gen-label">主诉 *</text>
|
||||
<textarea
|
||||
v-if="editingKey == 'chief_complaint'"
|
||||
class="ai-gen-textarea"
|
||||
:value="aiChief"
|
||||
maxlength="500"
|
||||
auto-height
|
||||
placeholder="请确认或补充主诉"
|
||||
@input="onAiChiefInput"
|
||||
@blur="onGenFieldBlur"
|
||||
/>
|
||||
<text
|
||||
v-else
|
||||
class="ai-gen-val"
|
||||
data-key="chief_complaint"
|
||||
@click="onStartEditTap"
|
||||
>{{ aiChief || '(空,点击填写)' }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(f, fi) in genOptionalFields"
|
||||
:key="fi"
|
||||
class="ai-gen-row"
|
||||
>
|
||||
<text class="ai-gen-label">{{ f.label }}</text>
|
||||
<textarea
|
||||
v-if="editingKey == f.key"
|
||||
class="ai-gen-textarea"
|
||||
:value="genContext[f.key] || ''"
|
||||
maxlength="1000"
|
||||
auto-height
|
||||
:data-key="f.key"
|
||||
:placeholder="'选填' + f.label"
|
||||
@input="onGenContextInput"
|
||||
@blur="onGenFieldBlur"
|
||||
/>
|
||||
<text
|
||||
v-else
|
||||
class="ai-gen-val"
|
||||
:data-key="f.key"
|
||||
@click="onStartEditTap"
|
||||
>{{ genContext[f.key] || '(空,点击填写)' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ai-gen-scroll-pad" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="ai-mr-footer safe-area-inset-bottom">
|
||||
<view v-if="!genModalVisible" class="ai-actions">
|
||||
<view v-if="consentVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onConsentCancel">取消</view>
|
||||
<view class="ai-btn ai-btn--primary" @click="onConsentAgree">
|
||||
{{ consentSubmitting ? '提交中…' : '我已知悉并同意' }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!genModalVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onClose">关闭</view>
|
||||
<view class="ai-btn ai-btn--ghost" @click="openGenModal">生成病历</view>
|
||||
<view class="ai-btn ai-btn--primary" @click="onConfirmImport">确认导入</view>
|
||||
@@ -110,6 +179,8 @@
|
||||
|
||||
<script>
|
||||
import {
|
||||
aiAgreeConsentApi,
|
||||
aiFeatureGateApi,
|
||||
aiGenerateMedicalRecordApi,
|
||||
aiGenerationDetailApi,
|
||||
aiListGenerationsApi,
|
||||
@@ -125,6 +196,7 @@ const PREVIEW_DEFS = [
|
||||
{ key: 'tcm_method', label: '中医治法' },
|
||||
{ key: 'diagnosis', label: '临床诊断' },
|
||||
{ key: 'treatment_advice', label: '治疗意见' },
|
||||
{ key: 'doctor_order', label: '医嘱' },
|
||||
{ key: 'family_history', label: '家族史' },
|
||||
{ key: 'epidemic_history', label: '流行病学史' },
|
||||
{ key: 'past_history', label: '既往史' },
|
||||
@@ -136,6 +208,29 @@ const PREVIEW_DEFS = [
|
||||
{ key: 'auxiliary_exam', label: '辅助检查' },
|
||||
]
|
||||
|
||||
/** 生成前可选填(非必填) */
|
||||
const GEN_OPTIONAL_DEFS = [
|
||||
{ key: 'present_illness', label: '现病史' },
|
||||
{ key: 'past_history', label: '既往史' },
|
||||
{ key: 'allergy_history', label: '过敏史' },
|
||||
{ key: 'menstrual_history', label: '月经史' },
|
||||
{ key: 'family_history', label: '家族史' },
|
||||
{ key: 'marital_history', label: '婚育史' },
|
||||
{ key: 'personal_history', label: '个人史' },
|
||||
{ key: 'physical_exam', label: '体征检查' },
|
||||
{ key: 'auxiliary_exam', label: '辅助检查' },
|
||||
{ key: 'tongue', label: '舌象' },
|
||||
{ key: 'pulse', label: '脉象' },
|
||||
]
|
||||
|
||||
function emptyGenContext() {
|
||||
const o = {}
|
||||
for (let i = 0; i < GEN_OPTIONAL_DEFS.length; i++) {
|
||||
o[GEN_OPTIONAL_DEFS[i].key] = ''
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'AiMedicalRecordDrawer',
|
||||
props: {
|
||||
@@ -146,12 +241,17 @@ export default {
|
||||
patientSex: { type: [Number, String], default: 0 },
|
||||
patientAge: { type: [Number, String], default: 0 },
|
||||
chiefComplaint: { type: String, default: '' },
|
||||
medicalRecord: { type: Object, default: () => ({}) },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
genModalVisible: false,
|
||||
aiChief: '',
|
||||
genOptionalFields: GEN_OPTIONAL_DEFS,
|
||||
genContext: emptyGenContext(),
|
||||
/** 生成面板点击编辑的字段 key */
|
||||
editingKey: '',
|
||||
histLoading: false,
|
||||
generating: false,
|
||||
detailLoading: false,
|
||||
@@ -162,6 +262,11 @@ export default {
|
||||
previewFields: {},
|
||||
previewChief: '',
|
||||
previewRows: [],
|
||||
lastDurationMs: 0,
|
||||
disclaimerText: '',
|
||||
consentText: '',
|
||||
consentVisible: false,
|
||||
consentSubmitting: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -171,6 +276,9 @@ export default {
|
||||
if (s === 2) return '女'
|
||||
return '未知'
|
||||
},
|
||||
durationText() {
|
||||
return this.formatDurationMs(this.lastDurationMs)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
@@ -188,8 +296,44 @@ export default {
|
||||
methods: {
|
||||
bootstrapOpen() {
|
||||
this.aiChief = String(this.chiefComplaint || '').trim()
|
||||
const next = emptyGenContext()
|
||||
const snap = this.medicalRecord || {}
|
||||
for (let i = 0; i < GEN_OPTIONAL_DEFS.length; i++) {
|
||||
const k = GEN_OPTIONAL_DEFS[i].key
|
||||
next[k] = String(snap[k] || '').trim()
|
||||
}
|
||||
this.genContext = next
|
||||
this.genModalVisible = false
|
||||
this.editingKey = ''
|
||||
this.consentVisible = false
|
||||
this.consentSubmitting = false
|
||||
this.disclaimerText = ''
|
||||
this.consentText = ''
|
||||
this.resetPreview()
|
||||
this.ensureConsentThenBootstrap()
|
||||
},
|
||||
/**
|
||||
* 首次使用 AI 写病历:未签署则先展示知情同意,同意后再加载历史
|
||||
*/
|
||||
async ensureConsentThenBootstrap() {
|
||||
try {
|
||||
const res = await aiFeatureGateApi({ feature_code: 'ai_medical_record' })
|
||||
const gate = this.unwrapBiz(res) || {}
|
||||
this.disclaimerText = String(gate.disclaimer_text || '')
|
||||
this.consentText = String(gate.consent_text || '')
|
||||
if (gate.consented) {
|
||||
this.afterConsentReady()
|
||||
return
|
||||
}
|
||||
this.consentVisible = true
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '无法校验 AI 使用规范'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
this.onClose()
|
||||
}
|
||||
},
|
||||
afterConsentReady() {
|
||||
this.consentVisible = false
|
||||
this.loadHistory().then(() => {
|
||||
if (this.historyList.length) {
|
||||
this.selectHistoryById(Number(this.historyList[0].id))
|
||||
@@ -198,11 +342,32 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
onConsentCancel() {
|
||||
this.onClose()
|
||||
},
|
||||
async onConsentAgree() {
|
||||
if (this.consentSubmitting) return
|
||||
this.consentSubmitting = true
|
||||
try {
|
||||
await aiAgreeConsentApi({
|
||||
feature_code: 'ai_medical_record',
|
||||
store_id: Number(this.storeId || 0),
|
||||
})
|
||||
uni.showToast({ title: '已记录知情同意', icon: 'none' })
|
||||
this.afterConsentReady()
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '签署失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.consentSubmitting = false
|
||||
}
|
||||
},
|
||||
resetPreview() {
|
||||
this.activeId = 0
|
||||
this.previewFields = {}
|
||||
this.previewChief = ''
|
||||
this.previewRows = []
|
||||
this.lastDurationMs = 0
|
||||
},
|
||||
onClose() {
|
||||
this.visible = false
|
||||
@@ -227,13 +392,21 @@ export default {
|
||||
const pad = (x) => (x < 10 ? '0' + x : '' + x)
|
||||
return pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
},
|
||||
/** 毫秒转可读耗时(850ms / 1.2s) */
|
||||
formatDurationMs(ms) {
|
||||
const n = Number(ms || 0)
|
||||
if (!n) return ''
|
||||
if (n < 1000) return Math.round(n) + 'ms'
|
||||
const sec = n / 1000
|
||||
return sec >= 10 ? Math.round(sec) + 's' : sec.toFixed(1) + 's'
|
||||
},
|
||||
rebuildHistoryView() {
|
||||
const list = Array.isArray(this.historyList) ? this.historyList : []
|
||||
this.historyEmpty = list.length === 0
|
||||
this.historyView = list.map((row) => ({
|
||||
id: Number(row.id || 0),
|
||||
nameText: String(row.name || '').trim() || ('病历 #' + row.id),
|
||||
timeText: this.formatTime(row.created_at),
|
||||
timeText: this.formatTime(row.created_at) + (row.duration_ms ? (' · ' + this.formatDurationMs(row.duration_ms)) : ''),
|
||||
}))
|
||||
},
|
||||
rebuildPreviewRows() {
|
||||
@@ -274,10 +447,32 @@ export default {
|
||||
},
|
||||
closeGenModal() {
|
||||
this.genModalVisible = false
|
||||
this.editingKey = ''
|
||||
},
|
||||
onAiChiefInput(e) {
|
||||
this.aiChief = (e && e.detail && e.detail.value) || ''
|
||||
},
|
||||
/** 点击字段进入编辑(对齐 PC / 出方生成面板交互) */
|
||||
onStartEditTap(e) {
|
||||
const key = e && e.currentTarget && e.currentTarget.dataset
|
||||
? e.currentTarget.dataset.key
|
||||
: ''
|
||||
if (!key) return
|
||||
this.editingKey = key
|
||||
},
|
||||
onGenFieldBlur() {
|
||||
this.editingKey = ''
|
||||
},
|
||||
onGenContextInput(e) {
|
||||
const key = e && e.currentTarget && e.currentTarget.dataset
|
||||
? e.currentTarget.dataset.key
|
||||
: ''
|
||||
const val = (e && e.detail && e.detail.value) || ''
|
||||
if (!key) return
|
||||
const next = Object.assign({}, this.genContext || {})
|
||||
next[key] = val
|
||||
this.genContext = next
|
||||
},
|
||||
async onGenerate() {
|
||||
if (this.generating) return
|
||||
const chief = String(this.aiChief || '').trim()
|
||||
@@ -293,19 +488,35 @@ export default {
|
||||
this.generating = true
|
||||
this.previewFields = {}
|
||||
this.previewChief = chief
|
||||
this.lastDurationMs = 0
|
||||
this.rebuildPreviewRows()
|
||||
try {
|
||||
const res = await aiGenerateMedicalRecordApi({
|
||||
register_id: Number(this.registerId),
|
||||
store_id: Number(this.storeId || 0) || undefined,
|
||||
chief_complaint: chief,
|
||||
medical_record: Object.assign({}, this.genContext || {}),
|
||||
})
|
||||
const data = this.unwrapBiz(res) || {}
|
||||
const durationText = this.formatDurationMs(data.duration_ms)
|
||||
this.lastDurationMs = Number(data.duration_ms || 0)
|
||||
// 业务软失败:HTTP 成功但 ok=false,toast 警告而非失败
|
||||
if (data.ok === false) {
|
||||
if (data.generation_id) {
|
||||
this.activeId = Number(data.generation_id)
|
||||
await this.loadHistory()
|
||||
}
|
||||
uni.showToast({ title: String(data.message || '生成未成功'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.previewFields = data.fields || {}
|
||||
this.rebuildPreviewRows()
|
||||
await this.loadHistory()
|
||||
this.activeId = Number(data.generation_id || 0)
|
||||
uni.showToast({ title: '已生成', icon: 'success' })
|
||||
uni.showToast({
|
||||
title: durationText ? ('已生成(耗时 ' + durationText + ')') : '已生成',
|
||||
icon: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '生成失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
@@ -330,6 +541,13 @@ export default {
|
||||
this.previewFields = data.fields || {}
|
||||
const snap = data.input_snapshot || {}
|
||||
this.previewChief = String(snap.chief_complaint || this.aiChief || '').trim()
|
||||
this.lastDurationMs = 0
|
||||
for (let i = 0; i < this.historyList.length; i++) {
|
||||
if (Number(this.historyList[i].id) === id) {
|
||||
this.lastDurationMs = Number(this.historyList[i].duration_ms || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.rebuildPreviewRows()
|
||||
} catch (e) {
|
||||
this.previewFields = {}
|
||||
@@ -377,6 +595,17 @@ export default {
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-gen-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
padding: 0 24rpx 16rpx;
|
||||
box-sizing: border-box;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.ai-gen-scroll-pad {
|
||||
height: 32rpx;
|
||||
}
|
||||
.ai-mr-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -463,6 +692,49 @@ export default {
|
||||
padding: 16rpx 24rpx 24rpx;
|
||||
background: #fff;
|
||||
}
|
||||
.ai-disclaimer {
|
||||
margin: 0 24rpx 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.ai-disclaimer-title {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.ai-disclaimer-body {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
color: #78716c;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-consent-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
.ai-consent-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-consent-body {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.65;
|
||||
color: #2a2e35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -491,27 +763,59 @@ export default {
|
||||
padding: 16rpx 32rpx 8rpx;
|
||||
}
|
||||
.ai-gen-tip {
|
||||
display: block;
|
||||
padding: 0 32rpx 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
.ai-gen-patient,
|
||||
.ai-gen-field {
|
||||
margin: 0 24rpx 16rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
.ai-gen-patient {
|
||||
margin: 0 6rpx 12rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #fff;
|
||||
border-radius: 10rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-gen-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -6rpx;
|
||||
}
|
||||
.ai-gen-row {
|
||||
width: calc(50% - 12rpx);
|
||||
margin: 0 6rpx 8rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #fff;
|
||||
border-radius: 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-gen-row--full {
|
||||
width: calc(100% - 12rpx);
|
||||
}
|
||||
.ai-gen-label {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
.ai-gen-val {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #2a2e35;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-gen-textarea {
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 26rpx;
|
||||
min-height: 72rpx;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
color: #2a2e35;
|
||||
background: #fff;
|
||||
padding: 12rpx;
|
||||
background: #f7f8fa;
|
||||
padding: 8rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
@@ -527,6 +831,9 @@ export default {
|
||||
.m-t-8 {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.fs-20 {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.fs-22 {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,22 @@
|
||||
<text class="ai-rx-title">AI辅助出方</text>
|
||||
<text class="ai-rx-sub">{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || 0 }}岁</text>
|
||||
</view>
|
||||
<view v-if="conflictView.length && !genModalVisible" class="ai-conflict">
|
||||
<view v-if="disclaimerText && !consentVisible" class="ai-disclaimer">
|
||||
<text class="ai-disclaimer-title">重要提示</text>
|
||||
<text class="ai-disclaimer-body">{{ disclaimerText }}</text>
|
||||
</view>
|
||||
<view v-if="consentVisible" class="ai-consent-panel">
|
||||
<text class="fs-30 font-bold">AI 使用知情同意</text>
|
||||
<scroll-view scroll-y class="ai-consent-scroll">
|
||||
<text class="ai-consent-body">{{ consentText }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-else-if="conflictView.length && !genModalVisible" class="ai-conflict">
|
||||
<text class="ai-conflict-title">配伍禁忌提示</text>
|
||||
<view v-for="(line, ci) in conflictView" :key="ci" class="ai-conflict-line">· {{ line }}</view>
|
||||
</view>
|
||||
<!-- 主内容:历史 + 对照 -->
|
||||
<view v-if="!genModalVisible" class="ai-rx-body flex-row">
|
||||
<view v-if="!consentVisible && !genModalVisible" class="ai-rx-body flex-row">
|
||||
<scroll-view scroll-y class="ai-rx-hist">
|
||||
<view class="ai-hist-hd">
|
||||
<text class="fs-24 font-bold">历史</text>
|
||||
@@ -44,9 +54,18 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-y class="ai-rx-match">
|
||||
<view v-if="generating || matchLoading" class="p-32 color-sub">AI 处理中…</view>
|
||||
<!-- AI 专属 Loading:仅生成中;历史对照用普通文案 -->
|
||||
<ai-generation-loading
|
||||
v-if="generating"
|
||||
type="prescription"
|
||||
text="AI正在生成处方"
|
||||
/>
|
||||
<view v-else-if="matchLoading" class="p-32 color-sub fs-24">加载历史处方…</view>
|
||||
<block v-else>
|
||||
<view v-if="!matchResult" class="p-32 color-sub fs-24">请选择历史或生成处方</view>
|
||||
<view v-if="!matchResult && !lastDurationMs" class="p-32 color-sub fs-24">请选择历史或生成处方</view>
|
||||
<view v-else-if="!matchResult && lastDurationMs" class="p-32 color-sub fs-24">
|
||||
本次生成未产出对照结果(耗时 {{ durationText }})
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="ai-sec-title">已对照({{ matchedCount }})</view>
|
||||
<view v-if="isTcmRx" class="ai-tcm-grid">
|
||||
@@ -115,7 +134,7 @@
|
||||
<text class="ai-link m-t-8" :data-ui="u.ui" @click="onSearchUnmatchedTap">去手动搜索</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="rxPrescriptionName || rxReason || rxBasis || rxDosage || rxDayDosage"
|
||||
v-if="rxPrescriptionName || rxReason || rxBasis || rxDosage || rxDayDosage || lastDurationMs || rxProcessRuleName"
|
||||
class="ai-rx-meta m-t-16"
|
||||
>
|
||||
<view class="ai-desc-row ai-desc-row--full">
|
||||
@@ -130,6 +149,14 @@
|
||||
<text class="ai-desc-label">每天几次</text>
|
||||
<text class="ai-desc-val">{{ rxDayDosage || '—' }}次</text>
|
||||
</view>
|
||||
<view v-if="lastDurationMs" class="ai-desc-row">
|
||||
<text class="ai-desc-label">生成耗时</text>
|
||||
<text class="ai-desc-val">{{ durationText }}</text>
|
||||
</view>
|
||||
<view v-if="rxProcessRuleName" class="ai-desc-row ai-desc-row--full">
|
||||
<text class="ai-desc-label">制剂要求</text>
|
||||
<text class="ai-desc-val">{{ rxProcessRuleText }}</text>
|
||||
</view>
|
||||
<view v-if="rxReason" class="ai-desc-row ai-desc-row--full">
|
||||
<text class="ai-desc-label">为什么出这个方</text>
|
||||
<text class="ai-desc-val">{{ rxReason }}</text>
|
||||
@@ -144,7 +171,7 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- 二级:抽屉内全屏生成面板(不用第二个 popup) -->
|
||||
<view v-else class="ai-gen-panel">
|
||||
<view v-else-if="!consentVisible && genModalVisible" class="ai-gen-panel">
|
||||
<view class="ai-gen-hd flex-row flex-jus-sp flex-ali-center">
|
||||
<text class="fs-30 font-bold">生成处方</text>
|
||||
<text class="ai-rx-sub" @click="closeGenModal">返回</text>
|
||||
@@ -177,10 +204,41 @@
|
||||
>{{ row.value || '(空,点击填写)' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 中药:生成前可选委托调剂 + 制剂要求 -->
|
||||
<view v-if="isTcmRx" class="ai-entrust-box">
|
||||
<view class="flex-row flex-ali-center flex-jus-sp">
|
||||
<text class="fs-24">委托调剂</text>
|
||||
<switch
|
||||
:checked="useEntrustedProcess"
|
||||
color="#6ACDBB"
|
||||
style="transform: scale(0.75)"
|
||||
@change="onEntrustedSwitch"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="useEntrustedProcess" class="m-t-12">
|
||||
<text class="fs-22 color-sub">制剂要求</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="processRuleNameList"
|
||||
:value="processRulePickerIndex"
|
||||
@change="onProcessRulePick"
|
||||
>
|
||||
<view class="ai-process-pick">
|
||||
<text class="fs-24">{{ processRulePickLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="ai-rx-footer safe-area-inset-bottom">
|
||||
<view v-if="!genModalVisible" class="ai-actions">
|
||||
<view v-if="consentVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onConsentCancel">取消</view>
|
||||
<view class="ai-btn ai-btn--primary" @click="onConsentAgree">
|
||||
{{ consentSubmitting ? '提交中…' : '我已知悉并同意' }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!genModalVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onClose">关闭</view>
|
||||
<view class="ai-btn ai-btn--ghost" @click="openGenModal">生成处方</view>
|
||||
<view class="ai-btn ai-btn--primary" @click="onConfirmImport">确认导入</view>
|
||||
@@ -198,9 +256,12 @@
|
||||
|
||||
<script>
|
||||
import {
|
||||
aiAgreeConsentApi,
|
||||
aiFeatureGateApi,
|
||||
aiGeneratePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
getProcessRuleList,
|
||||
} from '@/api/reception.js'
|
||||
|
||||
const MR_EDIT_FIELDS = [
|
||||
@@ -265,7 +326,23 @@ export default {
|
||||
rxReason: '',
|
||||
rxBasis: '',
|
||||
rxPrescriptionName: '',
|
||||
lastDurationMs: 0,
|
||||
rxProcessRuleId: 0,
|
||||
rxChildProcessRuleId: 0,
|
||||
rxProcessRuleNoteId: 0,
|
||||
rxProcessRuleName: '',
|
||||
rxChildProcessRuleName: '',
|
||||
rxProcessRuleNote: '',
|
||||
useEntrustedProcess: false,
|
||||
genProcessRuleId: 0,
|
||||
processRuleList: [],
|
||||
processRulePickerIndex: 0,
|
||||
processRuleLoading: false,
|
||||
genPreviewView: [],
|
||||
disclaimerText: '',
|
||||
consentText: '',
|
||||
consentVisible: false,
|
||||
consentSubmitting: false,
|
||||
importBtnStyle: {
|
||||
backgroundColor: '#6ACDBB',
|
||||
color: '#fff',
|
||||
@@ -290,6 +367,30 @@ export default {
|
||||
isTcmRx() {
|
||||
return Number(this.prescriptionType) === 1
|
||||
},
|
||||
/** 毫秒转可读耗时,供模板展示 */
|
||||
durationText() {
|
||||
return this.formatDurationMs(this.lastDurationMs)
|
||||
},
|
||||
processRuleNameList() {
|
||||
const list = Array.isArray(this.processRuleList) ? this.processRuleList : []
|
||||
return list.map((item) => String(item.name || item.title || ('#' + item.id)))
|
||||
},
|
||||
processRulePickLabel() {
|
||||
if (this.genProcessRuleId && this.processRuleList.length) {
|
||||
for (let i = 0; i < this.processRuleList.length; i++) {
|
||||
if (Number(this.processRuleList[i].id) === Number(this.genProcessRuleId)) {
|
||||
return String(this.processRuleList[i].name || this.processRuleList[i].title || ('#' + this.genProcessRuleId))
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.processRuleLoading ? '加载中…' : '请选择制剂要求'
|
||||
},
|
||||
rxProcessRuleText() {
|
||||
let text = this.rxProcessRuleName || ''
|
||||
if (this.rxChildProcessRuleName) text += ' · ' + this.rxChildProcessRuleName
|
||||
if (this.rxProcessRuleNote) text += ' · ' + this.rxProcessRuleNote
|
||||
return text
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
@@ -310,16 +411,67 @@ export default {
|
||||
this.localMr = Object.assign({}, this.medicalRecord || {})
|
||||
this.genModalVisible = false
|
||||
this.editingKey = ''
|
||||
this.consentVisible = false
|
||||
this.consentSubmitting = false
|
||||
this.disclaimerText = ''
|
||||
this.consentText = ''
|
||||
this.useEntrustedProcess = false
|
||||
this.genProcessRuleId = 0
|
||||
this.processRuleList = []
|
||||
this.processRulePickerIndex = 0
|
||||
this.resetPreview()
|
||||
this.ensureConsentThenBootstrap()
|
||||
},
|
||||
/**
|
||||
* 首次使用 AI 出方:未签署则先展示知情同意
|
||||
*/
|
||||
async ensureConsentThenBootstrap() {
|
||||
try {
|
||||
const res = await aiFeatureGateApi({ feature_code: 'ai_prescription' })
|
||||
const gate = this.unwrapBiz(res) || {}
|
||||
this.disclaimerText = String(gate.disclaimer_text || '')
|
||||
this.consentText = String(gate.consent_text || '')
|
||||
if (gate.consented) {
|
||||
this.afterConsentReady()
|
||||
return
|
||||
}
|
||||
this.consentVisible = true
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '无法校验 AI 使用规范'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
this.onClose()
|
||||
}
|
||||
},
|
||||
afterConsentReady() {
|
||||
this.consentVisible = false
|
||||
this.loadHistory().then(() => {
|
||||
if (this.historyList.length) {
|
||||
this.selectHistoryById(Number(this.historyList[0].id))
|
||||
} else {
|
||||
// 无历史:自动打开生成框
|
||||
this.openGenModal()
|
||||
}
|
||||
})
|
||||
},
|
||||
onConsentCancel() {
|
||||
this.onClose()
|
||||
},
|
||||
async onConsentAgree() {
|
||||
if (this.consentSubmitting) return
|
||||
this.consentSubmitting = true
|
||||
try {
|
||||
await aiAgreeConsentApi({
|
||||
feature_code: 'ai_prescription',
|
||||
store_id: Number(this.storeId || 0),
|
||||
})
|
||||
uni.showToast({ title: '已记录知情同意', icon: 'none' })
|
||||
this.afterConsentReady()
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '签署失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.consentSubmitting = false
|
||||
}
|
||||
},
|
||||
resetPreview() {
|
||||
this.matchResult = null
|
||||
this.matchedList = []
|
||||
@@ -330,6 +482,13 @@ export default {
|
||||
this.rxReason = ''
|
||||
this.rxBasis = ''
|
||||
this.rxPrescriptionName = ''
|
||||
this.lastDurationMs = 0
|
||||
this.rxProcessRuleId = 0
|
||||
this.rxChildProcessRuleId = 0
|
||||
this.rxProcessRuleNoteId = 0
|
||||
this.rxProcessRuleName = ''
|
||||
this.rxChildProcessRuleName = ''
|
||||
this.rxProcessRuleNote = ''
|
||||
this.activeId = 0
|
||||
this.rebuildMatchView()
|
||||
},
|
||||
@@ -357,6 +516,14 @@ export default {
|
||||
const pad = (x) => (x < 10 ? '0' + x : '' + x)
|
||||
return pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
},
|
||||
/** 毫秒转可读耗时(850ms / 1.2s) */
|
||||
formatDurationMs(ms) {
|
||||
const n = Number(ms || 0)
|
||||
if (!n) return ''
|
||||
if (n < 1000) return Math.round(n) + 'ms'
|
||||
const sec = n / 1000
|
||||
return sec >= 10 ? Math.round(sec) + 's' : sec.toFixed(1) + 's'
|
||||
},
|
||||
rebuildHistoryView() {
|
||||
const list = Array.isArray(this.historyList) ? this.historyList : []
|
||||
this.historyEmpty = list.length === 0
|
||||
@@ -374,7 +541,7 @@ export default {
|
||||
return {
|
||||
id: id,
|
||||
nameText: name || ('方案 #' + id),
|
||||
typeText: typeLabel + ' · ' + cnt + '味 · ' + this.formatTime(row.created_at),
|
||||
typeText: typeLabel + ' · ' + cnt + '味 · ' + this.formatTime(row.created_at) + (row.duration_ms ? (' · ' + this.formatDurationMs(row.duration_ms)) : ''),
|
||||
prescription_type: pt,
|
||||
}
|
||||
})
|
||||
@@ -427,6 +594,46 @@ export default {
|
||||
this.rxReason = String((data && data.reason) || '').trim()
|
||||
this.rxBasis = String((data && data.basis) || '').trim()
|
||||
this.rxPrescriptionName = String((data && data.prescription_name) || '').trim()
|
||||
if (data && data.duration_ms != null) {
|
||||
this.lastDurationMs = Number(data.duration_ms || 0)
|
||||
}
|
||||
this.rxProcessRuleId = Number((data && data.process_rule_id) || 0)
|
||||
this.rxChildProcessRuleId = Number((data && data.child_process_rule_id) || 0)
|
||||
this.rxProcessRuleNoteId = Number((data && data.process_rule_note_id) || 0)
|
||||
this.rxProcessRuleName = String((data && data.process_rule_name) || '').trim()
|
||||
this.rxChildProcessRuleName = String((data && data.child_process_rule_name) || '').trim()
|
||||
this.rxProcessRuleNote = String((data && data.process_rule_note) || '').trim()
|
||||
},
|
||||
/** 加载制剂要求(pid=0),供中药委托调剂选择 */
|
||||
async loadTopProcessRules() {
|
||||
if (!this.isTcmRx) return
|
||||
this.processRuleLoading = true
|
||||
try {
|
||||
const res = await getProcessRuleList({ pid: 0 })
|
||||
const list = this.unwrapBiz(res)
|
||||
this.processRuleList = Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
this.processRuleList = []
|
||||
} finally {
|
||||
this.processRuleLoading = false
|
||||
}
|
||||
},
|
||||
onEntrustedSwitch(e) {
|
||||
const checked = !!(e && e.detail && e.detail.value)
|
||||
this.useEntrustedProcess = checked
|
||||
if (checked && !this.processRuleList.length) {
|
||||
this.loadTopProcessRules()
|
||||
}
|
||||
if (!checked) {
|
||||
this.genProcessRuleId = 0
|
||||
this.processRulePickerIndex = 0
|
||||
}
|
||||
},
|
||||
onProcessRulePick(e) {
|
||||
const idx = Number((e && e.detail && e.detail.value) || 0)
|
||||
this.processRulePickerIndex = idx
|
||||
const item = this.processRuleList[idx]
|
||||
this.genProcessRuleId = item ? Number(item.id || 0) : 0
|
||||
},
|
||||
applyConflict(conflict) {
|
||||
const msgs = (conflict && conflict.messages) || []
|
||||
@@ -468,9 +675,11 @@ export default {
|
||||
uni.showToast({ title: '请先选择处方类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 抽屉内切到生成面板(单根 u-popup,避免 Vue2 双根第二个挂不上)
|
||||
this.editingKey = ''
|
||||
this.rebuildGenPreview()
|
||||
if (this.isTcmRx && this.useEntrustedProcess && !this.processRuleList.length) {
|
||||
this.loadTopProcessRules()
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.genModalVisible = true
|
||||
})
|
||||
@@ -527,11 +736,15 @@ export default {
|
||||
uni.showToast({ title: '请先选择处方类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && !this.genProcessRuleId) {
|
||||
uni.showToast({ title: '请选择制剂要求', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 先关二级弹窗,回主抽屉等待生成
|
||||
this.closeGenModal()
|
||||
this.generating = true
|
||||
try {
|
||||
const res = await aiGeneratePrescriptionApi({
|
||||
const req = {
|
||||
register_id: Number(this.registerId),
|
||||
store_id: Number(this.storeId || 0) || undefined,
|
||||
prescription_type: type,
|
||||
@@ -539,14 +752,38 @@ export default {
|
||||
medical_record: Object.assign({}, this.localMr || {}, {
|
||||
chief_complaint: chief,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && this.genProcessRuleId) {
|
||||
req.use_entrusted_process = 1
|
||||
req.process_rule_id = this.genProcessRuleId
|
||||
}
|
||||
const res = await aiGeneratePrescriptionApi(req)
|
||||
const data = this.unwrapBiz(res) || {}
|
||||
const durationText = this.formatDurationMs(data.duration_ms)
|
||||
// 业务软失败:HTTP 成功但 ok=false,toast 警告而非失败
|
||||
if (data.ok === false) {
|
||||
this.matchResult = null
|
||||
this.matchedList = []
|
||||
this.unmatchedList = []
|
||||
this.conflictView = []
|
||||
this.rebuildMatchView()
|
||||
this.applyRxMeta({ duration_ms: data.duration_ms })
|
||||
if (data.generation_id) {
|
||||
this.activeId = Number(data.generation_id)
|
||||
await this.loadHistory()
|
||||
}
|
||||
uni.showToast({ title: String(data.message || '生成未成功'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
await this.loadHistory()
|
||||
this.activeId = Number(data.generation_id || 0)
|
||||
this.applyMatch(data.match || null)
|
||||
this.applyConflict(data.conflict || null)
|
||||
this.applyRxMeta(data)
|
||||
uni.showToast({ title: '已生成', icon: 'success' })
|
||||
uni.showToast({
|
||||
title: durationText ? ('已生成(耗时 ' + durationText + ')') : '已生成',
|
||||
icon: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '生成失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
@@ -568,11 +805,13 @@ export default {
|
||||
try {
|
||||
let pt = Number(this.prescriptionType || 0)
|
||||
let histName = ''
|
||||
let histRow = null
|
||||
for (let i = 0; i < this.historyList.length; i++) {
|
||||
if (Number(this.historyList[i].id) === id) {
|
||||
pt = Number(this.historyList[i].prescription_type || pt)
|
||||
histRow = this.historyList[i]
|
||||
pt = Number(histRow.prescription_type || pt)
|
||||
histName = String(
|
||||
this.historyList[i].name || this.historyList[i].prescription_name || '',
|
||||
histRow.name || histRow.prescription_name || '',
|
||||
).trim()
|
||||
break
|
||||
}
|
||||
@@ -591,6 +830,13 @@ export default {
|
||||
day_dosage: data.day_dosage,
|
||||
reason: data.reason,
|
||||
basis: data.basis,
|
||||
duration_ms: histRow ? histRow.duration_ms : 0,
|
||||
process_rule_id: data.process_rule_id,
|
||||
child_process_rule_id: data.child_process_rule_id,
|
||||
process_rule_note_id: data.process_rule_note_id,
|
||||
process_rule_name: data.process_rule_name,
|
||||
child_process_rule_name: data.child_process_rule_name,
|
||||
process_rule_note: data.process_rule_note,
|
||||
prescription_name: data.prescription_name || histName,
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -660,6 +906,10 @@ export default {
|
||||
rows: drugs,
|
||||
dosage: this.rxDosage || undefined,
|
||||
day_dosage: this.rxDayDosage || undefined,
|
||||
rule_type: this.rxProcessRuleId > 0 ? 2 : undefined,
|
||||
process_rule_id: this.rxProcessRuleId > 0 ? this.rxProcessRuleId : undefined,
|
||||
child_process_rule_id: this.rxChildProcessRuleId > 0 ? this.rxChildProcessRuleId : undefined,
|
||||
process_rule_note_id: this.rxProcessRuleNoteId > 0 ? this.rxProcessRuleNoteId : undefined,
|
||||
})
|
||||
this.visible = false
|
||||
},
|
||||
@@ -876,6 +1126,49 @@ export default {
|
||||
padding: 16rpx 24rpx 24rpx;
|
||||
background: #fff;
|
||||
}
|
||||
.ai-disclaimer {
|
||||
margin: 0 24rpx 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.ai-disclaimer-title {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.ai-disclaimer-body {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
color: #78716c;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-consent-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
.ai-consent-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-consent-body {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.65;
|
||||
color: #2a2e35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -955,6 +1248,22 @@ export default {
|
||||
box-sizing: border-box;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
.ai-entrust-box {
|
||||
margin: 16rpx 6rpx 0;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.ai-process-pick {
|
||||
margin-top: 8rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.m-t-12 {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.color-sub {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -356,7 +356,8 @@
|
||||
>
|
||||
<view class="entry-row-top">
|
||||
<view class="entry-row-name">
|
||||
<block v-for="(p, pi) in (row._titleParts || [])" :key="'t' + pi">
|
||||
<!-- 非 H5 :key 仅支持简单变量,禁止 't'+pi 这类拼接 -->
|
||||
<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>
|
||||
@@ -365,7 +366,7 @@
|
||||
</view>
|
||||
<!-- 正文多行展示,行高收紧以一屏多看几条 -->
|
||||
<view v-if="row.content" class="entry-row-content">
|
||||
<block v-for="(p, pi) in (row._contentParts || [])" :key="'c' + pi">
|
||||
<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>
|
||||
@@ -376,7 +377,7 @@
|
||||
>
|
||||
<text v-if="row.remark" class="entry-remark-tag">{{ row.remark }}</text>
|
||||
<text v-if="entryKeyword && row.pinyin_initials" class="entry-py-tag">
|
||||
<block v-for="(p, pi) in (row._pyParts || [])" :key="'p' + pi">
|
||||
<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>
|
||||
@@ -390,6 +391,7 @@
|
||||
</u-popup>
|
||||
|
||||
<!-- AI写病历:历史抽屉 + 二级生成(与出方一致) -->
|
||||
<!-- 注意:模板禁止 || {} / ?? / ?.,空对象字面量会编译成 WXML 的 $ 报错 -->
|
||||
<AiMedicalRecordDrawer
|
||||
v-model="aiShow"
|
||||
:register-id="registerId"
|
||||
@@ -398,6 +400,7 @@
|
||||
:patient-sex="patientSex"
|
||||
:patient-age="patientAge"
|
||||
:chief-complaint="(form && form.chief_complaint) || ''"
|
||||
:medical-record="form"
|
||||
@overlay-change="onAiOverlayChange"
|
||||
@import="onAiConfirmImport"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<!--
|
||||
金方导入弹窗(医生小程序)
|
||||
VIP:golden_formula;模板禁止 ?? ?. ;:class 禁止方法调用
|
||||
-->
|
||||
<u-popup
|
||||
v-model="show"
|
||||
mode="bottom"
|
||||
:closeable="true"
|
||||
@close="handleClose"
|
||||
:safe-area-inset-bottom="true"
|
||||
:mask-close-able="true"
|
||||
z-index="10078"
|
||||
height="82%"
|
||||
>
|
||||
<view class="gf-modal">
|
||||
<view class="gf-hd">
|
||||
<text class="fs-32 main-c font-bold">导入金方</text>
|
||||
</view>
|
||||
<view class="gf-search flex-row flex-ali-center">
|
||||
<u-input
|
||||
v-model="keyword"
|
||||
placeholder="名称/首拼/来源/速览"
|
||||
:clearable="true"
|
||||
:custom-style="{ background: '#f5f5f5', padding: '8rpx 16rpx' }"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<view class="gf-search-btn m-l-16" @click="onSearch">搜索</view>
|
||||
</view>
|
||||
<view class="gf-body flex-row">
|
||||
<scroll-view scroll-y class="gf-list">
|
||||
<view v-if="loading" class="p-24 color-sub fs-24">加载中…</view>
|
||||
<view v-else-if="!list.length" class="p-24 color-sub fs-24">暂无金方</view>
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="gf-item"
|
||||
:class="{ 'gf-item--on': selectedId == item.id }"
|
||||
:data-id="item.id"
|
||||
@click="onSelectTap"
|
||||
>
|
||||
<text class="fs-28 font-bold">{{ item.name }}</text>
|
||||
<text class="fs-22 color-sub m-t-8 line-clamp-2">{{ item.herb_overview || '—' }}</text>
|
||||
<text v-if="item.source" class="gf-source m-t-8">{{ item.source }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-y class="gf-match">
|
||||
<view v-if="applying" class="p-24 color-sub fs-24">对照中…</view>
|
||||
<view v-else-if="!matchResult" class="p-24 color-sub fs-24">请选择左侧金方</view>
|
||||
<block v-else>
|
||||
<text class="fs-28 font-bold">{{ matchResult.formula && matchResult.formula.name }}</text>
|
||||
<view class="m-t-12 fs-22">
|
||||
已匹配 {{ matchedCount }} · 未匹配 {{ unmatchedCount }}
|
||||
</view>
|
||||
<view
|
||||
v-if="matchResult.formula && matchResult.formula.original_formula"
|
||||
class="gf-original m-t-12"
|
||||
>
|
||||
<text class="fs-22 font-bold">原方:</text>
|
||||
<text class="fs-20 color-sub">{{ matchResult.formula.original_formula }}</text>
|
||||
</view>
|
||||
<!-- 非 H5 :key 仅支持简单变量,禁止字符串拼接表达式 -->
|
||||
<view
|
||||
v-for="(m, idx) in (matchResult.matched || [])"
|
||||
:key="idx"
|
||||
class="gf-row gf-row--ok"
|
||||
>
|
||||
<text class="fs-24">{{ m.show_dose || m.display_text || (m.ai_name + ' ' + (m.dose || '') + (m.unit || '')) }}</text>
|
||||
<text class="fs-22 color-sub">
|
||||
→ {{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(u, idx) in (matchResult.unmatched || [])"
|
||||
:key="idx"
|
||||
class="gf-row gf-row--miss"
|
||||
>
|
||||
<text class="fs-24">{{ u.show_dose || u.display_text || u.ai_name }}(未匹配)</text>
|
||||
</view>
|
||||
<view class="gf-footer">
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
shape="circle"
|
||||
:disabled="matchedCount <= 0"
|
||||
:custom-style="{ backgroundColor: '#6ACDBB', color: '#fff', height: '80rpx' }"
|
||||
@click="handleConfirm"
|
||||
>确认导入到处方</u-button>
|
||||
</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 金方导入:列表检索 → 药名对照 → 确认导入(结构与 AI 出方 rows 一致)
|
||||
*/
|
||||
import {
|
||||
goldenFormulaListApi,
|
||||
goldenFormulaApplyApi,
|
||||
} from '@/api/reception.js';
|
||||
|
||||
export default {
|
||||
name: 'GoldenFormulaModal',
|
||||
props: {
|
||||
value: { type: Boolean, default: false },
|
||||
storeId: { type: [Number, String], default: 0 },
|
||||
registerId: { type: [Number, String], default: 0 },
|
||||
/** 开方页 activeCategory:1中药 2西药 */
|
||||
prescriptionType: { type: Number, default: 1 },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
loading: false,
|
||||
applying: false,
|
||||
keyword: '',
|
||||
list: [],
|
||||
selectedId: 0,
|
||||
matchResult: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
matchedCount() {
|
||||
const m = this.matchResult && this.matchResult.matched;
|
||||
return Array.isArray(m) ? m.length : 0;
|
||||
},
|
||||
unmatchedCount() {
|
||||
const u = this.matchResult && this.matchResult.unmatched;
|
||||
return Array.isArray(u) ? u.length : 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(v) {
|
||||
this.show = !!v;
|
||||
if (v) {
|
||||
this.keyword = '';
|
||||
this.matchResult = null;
|
||||
this.selectedId = 0;
|
||||
this.loadList();
|
||||
}
|
||||
},
|
||||
},
|
||||
show(v) {
|
||||
if (!v && this.value) this.$emit('input', false);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.show = false;
|
||||
this.$emit('input', false);
|
||||
},
|
||||
onSearch() {
|
||||
this.loadList();
|
||||
},
|
||||
async loadList() {
|
||||
this.loading = true;
|
||||
this.matchResult = null;
|
||||
this.selectedId = 0;
|
||||
try {
|
||||
const res = await goldenFormulaListApi({
|
||||
keyword: this.keyword,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
store_id: this.storeId || undefined,
|
||||
});
|
||||
this.list = (res && res.items) || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.$toast((e && e.message) || '加载金方失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
onSelectTap(e) {
|
||||
const id = Number((e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.id) || 0);
|
||||
if (!id) return;
|
||||
this.handleSelect(id);
|
||||
},
|
||||
async handleSelect(id) {
|
||||
this.selectedId = id;
|
||||
this.applying = true;
|
||||
this.matchResult = null;
|
||||
try {
|
||||
// ProductType:中药1 / 西药2,与 activeCategory 对齐
|
||||
const pt = Number(this.prescriptionType) === 2 ? 2 : 1;
|
||||
const res = await goldenFormulaApplyApi({
|
||||
id,
|
||||
store_id: this.storeId || undefined,
|
||||
register_id: this.registerId || undefined,
|
||||
prescription_type: pt,
|
||||
});
|
||||
this.matchResult = res || null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.$toast((e && e.message) || '对照失败');
|
||||
} finally {
|
||||
this.applying = false;
|
||||
}
|
||||
},
|
||||
handleConfirm() {
|
||||
const matched = (this.matchResult && this.matchResult.matched) || [];
|
||||
if (!matched.length) {
|
||||
this.$toast('没有可导入的已匹配药品');
|
||||
return;
|
||||
}
|
||||
const rows = matched.map((m) => {
|
||||
const candidates = m.candidates || [];
|
||||
const selectedId = Number(m.selected_drug_id || 0);
|
||||
let candidate = candidates[0] || {};
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (Number(candidates[i].drug_id) === selectedId) {
|
||||
candidate = candidates[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ai_name: m.ai_name,
|
||||
dose: m.dose,
|
||||
unit: m.unit,
|
||||
usage: m.usage,
|
||||
frequency: m.frequency,
|
||||
candidate,
|
||||
};
|
||||
});
|
||||
this.$emit('import', {
|
||||
rows,
|
||||
formulaName: (this.matchResult.formula && this.matchResult.formula.name) || '',
|
||||
});
|
||||
const unmatched = (this.matchResult && this.matchResult.unmatched) || [];
|
||||
if (unmatched.length) {
|
||||
this.$toast('已导入 ' + rows.length + ' 味;未匹配 ' + unmatched.length + ' 味请手动补录');
|
||||
} else {
|
||||
this.$toast('已导入金方');
|
||||
}
|
||||
this.handleClose();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gf-modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx 24rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gf-hd {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.gf-search {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.gf-search-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 24rpx;
|
||||
background: #6acdbb;
|
||||
color: #fff;
|
||||
border-radius: 32rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.gf-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gf-list {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-right: 1px solid #eee;
|
||||
padding-right: 12rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gf-match {
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
padding-left: 16rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gf-item {
|
||||
padding: 16rpx;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.gf-item--on {
|
||||
border-color: #6acdbb;
|
||||
background: rgba(106, 205, 187, 0.08);
|
||||
}
|
||||
.gf-source {
|
||||
display: inline-block;
|
||||
font-size: 20rpx;
|
||||
color: #b8860b;
|
||||
background: #fff8e6;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
.gf-row {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.gf-row--ok {
|
||||
background: #f0faf7;
|
||||
}
|
||||
.gf-row--miss {
|
||||
background: #fff7e6;
|
||||
}
|
||||
.gf-original {
|
||||
max-height: 160rpx;
|
||||
overflow-y: auto;
|
||||
padding: 12rpx;
|
||||
background: #fff8e6;
|
||||
border-radius: 8rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gf-footer {
|
||||
margin-top: 24rpx;
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -237,6 +237,18 @@
|
||||
:ripple="true">
|
||||
常用方
|
||||
</u-button>
|
||||
<view v-if="!isSpecialPrescriptionCartLocked && canUseGoldenFormula" class="m-l-16">
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
shape="circle"
|
||||
size="mini"
|
||||
:custom-style="{backgroundColor: 'transparent !important',border:'1px solid #D4A017',color:'#D4A017'}"
|
||||
plain
|
||||
@click="handleOpenGoldenFormulaModal"
|
||||
:ripple="true">
|
||||
金方
|
||||
</u-button>
|
||||
</view>
|
||||
<view v-if="!isSpecialPrescriptionCartLocked" class="m-l-16">
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
@@ -316,6 +328,18 @@
|
||||
:ripple="true">
|
||||
常用方
|
||||
</u-button>
|
||||
<view v-if="!isSpecialPrescriptionCartLocked && canUseGoldenFormula" class="m-l-16">
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
shape="circle"
|
||||
size="mini"
|
||||
:custom-style="{backgroundColor: 'transparent !important',border:'1px solid #D4A017',color:'#D4A017'}"
|
||||
plain
|
||||
@click="handleOpenGoldenFormulaModal"
|
||||
:ripple="true">
|
||||
金方
|
||||
</u-button>
|
||||
</view>
|
||||
<view v-if="!isSpecialPrescriptionCartLocked" class="m-l-16">
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
@@ -640,6 +664,16 @@
|
||||
@select="handleSelectCommonPrescription"
|
||||
/>
|
||||
|
||||
<!-- 金方导入弹窗 -->
|
||||
<GoldenFormulaModal
|
||||
v-if="showGoldenFormulaModal"
|
||||
v-model="showGoldenFormulaModal"
|
||||
:store-id="currentStoreId"
|
||||
:register-id="registerId"
|
||||
:prescription-type="activeCategory"
|
||||
@import="handleImportGoldenFormula"
|
||||
/>
|
||||
|
||||
<!-- 西药选择弹窗 -->
|
||||
<WesternMedicineModal
|
||||
v-model="showWesternMedicineModal"
|
||||
@@ -770,6 +804,7 @@ import MedicalRecordPanel from './components/MedicalRecordPanel.vue';
|
||||
import AiPrescriptionDrawer from './components/AiPrescriptionDrawer.vue';
|
||||
import { saveMedicalRecordApi } from '@/api/reception.js';
|
||||
import CommonPrescriptionModal from './components/modals/CommonPrescriptionModal.vue';
|
||||
import GoldenFormulaModal from './components/modals/GoldenFormulaModal.vue';
|
||||
import WesternMedicineModal from './components/modals/WesternMedicineModal.vue';
|
||||
import ChineseMedicineModal from './components/modals/ChineseMedicineModal.vue';
|
||||
import SimpleProductModal from './components/modals/SimpleProductModal.vue';
|
||||
@@ -813,6 +848,7 @@ const PRESCRIPTION_OVERLAY_KEYS = [
|
||||
'showWarehouseSelectDrawer',
|
||||
'showMedicalRecordOverlay',
|
||||
'showAiPrescriptionDrawer',
|
||||
'showGoldenFormulaModal',
|
||||
];
|
||||
|
||||
export default {
|
||||
@@ -823,6 +859,7 @@ export default {
|
||||
MedicalRecordPanel,
|
||||
AiPrescriptionDrawer,
|
||||
CommonPrescriptionModal,
|
||||
GoldenFormulaModal,
|
||||
WesternMedicineModal,
|
||||
ChineseMedicineModal,
|
||||
SimpleProductModal,
|
||||
@@ -896,6 +933,7 @@ export default {
|
||||
showDoctorOrderModal: false,
|
||||
showCommonPrescriptionModal: false,
|
||||
showAiPrescriptionDrawer: false,
|
||||
showGoldenFormulaModal: false,
|
||||
/** 打开 AI 出方时带入的主诉(可在抽屉内再编辑) */
|
||||
aiRxChiefComplaint: '',
|
||||
/** 打开 AI 出方时带入的完整病历快照 */
|
||||
@@ -1175,6 +1213,10 @@ export default {
|
||||
canUseAiMedicalRecord() {
|
||||
return this.hasStoreVipPermission('ai_medical_record');
|
||||
},
|
||||
/** 门店 VIP:金方导入 */
|
||||
canUseGoldenFormula() {
|
||||
return this.hasStoreVipPermission('golden_formula');
|
||||
},
|
||||
navbarTitle() {
|
||||
if (this.isSalespersonTransferMode) {
|
||||
return '传方';
|
||||
@@ -2047,6 +2089,23 @@ export default {
|
||||
this.saveToLocalStorage();
|
||||
},
|
||||
handleOpenCommonPrescriptionModal() { this.showCommonPrescriptionModal = true; },
|
||||
/**
|
||||
* 打开金方导入(VIP:golden_formula)
|
||||
*/
|
||||
handleOpenGoldenFormulaModal() {
|
||||
if (!this.canUseGoldenFormula) {
|
||||
this.$toast('当前门店未开通金方VIP功能');
|
||||
return;
|
||||
}
|
||||
if (guardSpecialPrescriptionCartEdit(this)) return;
|
||||
this.showGoldenFormulaModal = true;
|
||||
},
|
||||
/**
|
||||
* 金方确认导入:复用 AI 出方写入购物车
|
||||
*/
|
||||
handleImportGoldenFormula(payload) {
|
||||
this.handleImportAiPrescription(payload || {});
|
||||
},
|
||||
/**
|
||||
* 打开 AI 给处方抽屉:带入完整病历(含未保存),先确认再生成
|
||||
*/
|
||||
@@ -2065,7 +2124,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
const panel = this.$refs.medicalRecordPanel;
|
||||
const payload = panel && panel.getPayload ? panel.getPayload() : {};
|
||||
let payload = panel && panel.getPayload ? panel.getPayload() : {};
|
||||
// 面板未就绪时兜底本地草稿,避免只带上空对象出方
|
||||
const draft = PrescriptionStorage.loadMedicalRecordDraft(this.registerId);
|
||||
if (draft && typeof draft === 'object') {
|
||||
payload = Object.assign({}, draft, payload || {});
|
||||
}
|
||||
if (this.diagnosisText) payload.diagnosis = this.diagnosisText;
|
||||
if (this.medicalAdvice) payload.doctor_order = this.medicalAdvice;
|
||||
this.aiRxChiefComplaint = String((payload && payload.chief_complaint) || '');
|
||||
this.aiRxMedicalRecord = Object.assign({}, payload || {});
|
||||
this.showAiPrescriptionDrawer = true;
|
||||
@@ -2106,10 +2172,10 @@ export default {
|
||||
},
|
||||
/**
|
||||
* 将 AI 对照结果覆盖导入当前处方(非追加)
|
||||
* 中药同步剂数/每日次数,usage 映射先煎后下 way_id
|
||||
* @param {Object} payload { rows, dosage, day_dosage }
|
||||
* 中药同步剂数/每日次数,usage 映射先煎后下 way_id;含委托调剂级联
|
||||
* @param {Object} payload { rows, dosage, day_dosage, rule_type, process_rule_id, ... }
|
||||
*/
|
||||
handleImportAiPrescription(payload) {
|
||||
async handleImportAiPrescription(payload) {
|
||||
if (guardSpecialPrescriptionCartEdit(this)) return;
|
||||
const list = Array.isArray(payload && payload.rows) ? payload.rows : (Array.isArray(payload) ? payload : []);
|
||||
if (!list.length) {
|
||||
@@ -2123,7 +2189,22 @@ export default {
|
||||
const found = wayList.find((w) => String(w.name || '') === u || String(w.name || '').indexOf(u) >= 0);
|
||||
return found ? Number(found.id || 0) : 0;
|
||||
};
|
||||
const doOverwrite = () => {
|
||||
/** AI 返回委托调剂时,同步 ruleType 并级联加载制剂选项 */
|
||||
const applyAiProcessRules = async () => {
|
||||
if (this.activeCategory !== 1) return;
|
||||
const prId = payload && Number(payload.process_rule_id || 0);
|
||||
if (prId <= 0 && !(payload && payload.rule_type === 2)) return;
|
||||
const childId = payload && Number(payload.child_process_rule_id || 0);
|
||||
const noteId = payload && Number(payload.process_rule_note_id || 0);
|
||||
this.chineseConfig = Object.assign({}, this.chineseConfig, {
|
||||
ruleType: 2,
|
||||
processRuleId: prId > 0 ? prId : null,
|
||||
childProcessRuleId: childId > 0 ? childId : null,
|
||||
processRuleNoteId: noteId > 0 ? noteId : null,
|
||||
});
|
||||
await this.hydrateChineseProcessRuleListsFromConfig();
|
||||
};
|
||||
const doOverwrite = async () => {
|
||||
clearAppliedSpecialPrescription(this);
|
||||
const next = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
@@ -2204,6 +2285,7 @@ export default {
|
||||
if (payload && Number(payload.day_dosage) > 0) {
|
||||
this.chineseConfig.dayDosage = Number(payload.day_dosage);
|
||||
}
|
||||
await applyAiProcessRules();
|
||||
}
|
||||
this.saveToLocalStorage();
|
||||
this.$toast(`已覆盖导入 ${next.length} 个药品`);
|
||||
@@ -2218,7 +2300,7 @@ export default {
|
||||
});
|
||||
return;
|
||||
}
|
||||
doOverwrite();
|
||||
await doOverwrite();
|
||||
},
|
||||
async handleApplySpecialPrescription() {
|
||||
if (!this.registerId || this.applyingSpecialPrescription) return;
|
||||
@@ -2826,12 +2908,37 @@ export default {
|
||||
isOnlineRevisit: this.isOnlineRevisit,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 把相冲/有毒检查结果整理成 showModal 可用的纯文本
|
||||
* 接口 message 可能是 { poisonous, opposition, conflict } 对象
|
||||
*/
|
||||
formatConflictModalContent(message) {
|
||||
const lines = [];
|
||||
if (message && typeof message === 'object') {
|
||||
const poisonous = message.poisonous || [];
|
||||
const opposition = message.opposition || [];
|
||||
const conflict = message.conflict || [];
|
||||
for (let i = 0; i < poisonous.length; i++) lines.push(String(poisonous[i]));
|
||||
for (let i = 0; i < opposition.length; i++) lines.push(String(opposition[i]));
|
||||
for (let i = 0; i < conflict.length; i++) lines.push(String(conflict[i]));
|
||||
} else if (typeof message === 'string' && message) {
|
||||
lines.push(message);
|
||||
}
|
||||
const body = lines.length
|
||||
? lines.join('\n')
|
||||
: '检测到该处方内具有药物相冲或毒性药材';
|
||||
return body + '\n\n是否继续开方并在处方上二次签名?';
|
||||
},
|
||||
async checkChineseMedicineConflict() {
|
||||
if (this.activeCategory !== 1) return { hasConflict: false };
|
||||
try {
|
||||
const names = this.currentDrugs.map((item) => item.drug_name || item.name);
|
||||
const res = await checkChineseMedicineConflictApi({ names });
|
||||
if (res && res.is_exist === true) return { hasConflict: true, message: res.message || '检测到该处方内具有药物相冲' };
|
||||
// 兼容 {code,result} 包装:必须取 result 再看 is_exist
|
||||
const data = (res && (res.result != null ? res.result : (res.data != null ? res.data : res))) || {};
|
||||
if (data.is_exist === true) {
|
||||
return { hasConflict: true, message: data.message };
|
||||
}
|
||||
return { hasConflict: false };
|
||||
} catch (error) {
|
||||
console.error('检查中药相冲失败:', error);
|
||||
@@ -2853,17 +2960,22 @@ export default {
|
||||
this.isSubmitting = true;
|
||||
try {
|
||||
if (!this.registerStoreInfo) await this.loadRegisterStoreInfo();
|
||||
if (this.activeCategory === 1) {
|
||||
// 中药:有毒/十八反/十九畏时弹二次签名确认(对齐 PC / 旧开方页)
|
||||
if (this.activeCategory === 1 && !doctorSecondSign) {
|
||||
const conflictCheck = await this.checkChineseMedicineConflict();
|
||||
if (conflictCheck.hasConflict) {
|
||||
uni.showModal({
|
||||
title: '药物相冲提示',
|
||||
content: conflictCheck.message || '检测到该处方内具有药物相冲,是否继续开方?',
|
||||
title: '处方药品提示',
|
||||
content: this.formatConflictModalContent(conflictCheck.message),
|
||||
showCancel: true,
|
||||
cancelText: '调整药品',
|
||||
confirmText: '继续开方',
|
||||
confirmColor: '#6ACDBB',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) this.handleSendPrescriptionWithStoreSelection(1);
|
||||
else this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
fail: () => { this.isSubmitting = false; },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user