feat: 病历模块优化、AI辅助问诊、接入deepseek

This commit is contained in:
李琦
2026-08-06 18:08:27 +08:00
parent 87a0c02fb0
commit 2e3e767b6c
8 changed files with 1474 additions and 64 deletions

View File

@@ -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
})
}
/** 金方列表VIPgolden_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
})
}

View 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>

View File

@@ -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",

View File

@@ -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=falsetoast 警告而非失败
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;
}

View File

@@ -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=falsetoast 警告而非失败
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;
}

View File

@@ -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"
/>

View File

@@ -0,0 +1,333 @@
<template>
<!--
金方导入弹窗医生小程序
VIPgolden_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 },
/** 开方页 activeCategory1中药 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>

View File

@@ -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; },
/**
* 打开金方导入VIPgolden_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;
}