feat: 联调流式对话

This commit is contained in:
王天骄
2026-06-11 17:48:46 +08:00
parent cdbe16dde3
commit 37716f200b
29 changed files with 473 additions and 219 deletions
+140 -160
View File
@@ -104,56 +104,30 @@
<view v-if="physicalPanelVisible" class="exam-mask" @click="physicalPanelVisible = false">
<view class="physical-panel" @click.stop>
<view class="exam-header">
<text class="exam-title">录入体格检查</text>
<text class="exam-title">选择体格检查</text>
<button class="exam-close" aria-label="关闭" @click="physicalPanelVisible = false">
<view class="close-icon"></view>
</button>
</view>
<scroll-view class="physical-form" scroll-y>
<view class="vital-grid">
<view class="form-field">
<text class="field-label">体温</text>
<input class="field-input" v-model="physicalForm.temperature" type="digit" placeholder="36.8" placeholder-class="field-placeholder" />
<text class="field-unit"></text>
</view>
<view class="form-field">
<text class="field-label">心率</text>
<input class="field-input" v-model="physicalForm.pulse" type="number" placeholder="98" placeholder-class="field-placeholder" />
<text class="field-unit">/</text>
</view>
<view class="form-field">
<text class="field-label">呼吸</text>
<input class="field-input" v-model="physicalForm.respiration" type="number" placeholder="22" placeholder-class="field-placeholder" />
<text class="field-unit">/</text>
</view>
<view class="form-field">
<text class="field-label">血氧</text>
<input class="field-input" v-model="physicalForm.spo2" type="number" placeholder="96" placeholder-class="field-placeholder" />
<text class="field-unit">%</text>
</view>
<view class="exam-list">
<view v-if="physicalExamsLoading" class="exam-empty">
<text>体格检查加载中...</text>
</view>
<view class="form-field full">
<text class="field-label">血压</text>
<input class="field-input" v-model="physicalForm.bloodPressure" type="text" placeholder="138/86" placeholder-class="field-placeholder" />
<text class="field-unit">mmHg</text>
<view v-else-if="physicalExams.length === 0" class="exam-empty">
<text>暂无可选体格检查</text>
</view>
<view class="form-field full">
<text class="field-label">意识/面色</text>
<input class="field-input" v-model="physicalForm.complexion" type="text" placeholder="清醒,面色苍白,出汗" placeholder-class="field-placeholder" />
</view>
<view class="form-field textarea-field">
<text class="field-label">心肺/腹部查体</text>
<textarea class="field-textarea" v-model="physicalForm.examFinding" placeholder="心音、肺部啰音、腹部压痛等" placeholder-class="field-placeholder"></textarea>
</view>
<view class="form-field textarea-field">
<text class="field-label">其他发现</text>
<textarea class="field-textarea" v-model="physicalForm.otherFinding" placeholder="如双侧血压差、下肢水肿、神经系统体征等" placeholder-class="field-placeholder"></textarea>
</view>
</scroll-view>
<view class="physical-actions">
<button class="physical-cancel" @click="physicalPanelVisible = false">取消</button>
<button class="physical-submit" @click="submitPhysicalExam">提交检查结果</button>
<block v-else>
<button
v-for="exam in physicalExams"
:key="exam.item_code"
class="exam-item"
:disabled="examOrdering"
@click="selectPhysicalExam(exam)"
>
<text class="exam-name">{{ exam.item_name }}</text>
<view class="chevron-icon"></view>
</button>
</block>
</view>
</view>
</view>
@@ -167,15 +141,24 @@
</button>
</view>
<view class="exam-list">
<button
v-for="exam in auxiliaryExams"
:key="exam.name"
class="exam-item"
@click="selectAuxiliaryExam(exam)"
>
<text class="exam-name">{{ exam.name }}</text>
<view class="chevron-icon"></view>
</button>
<view v-if="auxiliaryExamsLoading" class="exam-empty">
<text>辅助检查加载中...</text>
</view>
<view v-else-if="auxiliaryExams.length === 0" class="exam-empty">
<text>暂无可选辅助检查</text>
</view>
<block v-else>
<button
v-for="exam in auxiliaryExams"
:key="exam.item_code"
class="exam-item"
:disabled="examOrdering"
@click="selectAuxiliaryExam(exam)"
>
<text class="exam-name">{{ exam.item_name }}</text>
<view class="chevron-icon"></view>
</button>
</block>
</view>
</view>
</view>
@@ -188,6 +171,14 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { readStoredClinicalCase, type ClinicalCase } from '../../api/cases'
import { createMockChatSession, sendMockChatMessage, type ChatMessage, type ChatSession } from '../../api/chat'
import {
fetchAuxiliaryExamItems,
fetchPhysicalExamItems,
orderAuxiliaryExamResult,
orderPhysicalExamResult,
type ExamItem,
type ExamResult
} from '../../api/exams'
import { createHomeNavigator, createProfileOpener, createSettingsOpener } from '../../api/navigation'
import {
completeInquiry,
@@ -238,46 +229,16 @@ const examPanelVisible = ref(false)
const physicalPanelVisible = ref(false)
const activeSessionId = ref<number | null>(null)
const storedCase = ref<ClinicalCase | null>(null)
const physicalExams = ref<ExamItem[]>([])
const auxiliaryExams = ref<ExamItem[]>([])
const physicalExamsLoading = ref(false)
const auxiliaryExamsLoading = ref(false)
const examOrdering = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let activeStreamController: AbortController | null = null
let activeHintController: AbortController | null = null
type AuxiliaryExam = {
name: string
result: string
}
const auxiliaryExams: AuxiliaryExam[] = [
{
name: '心电图',
result: '检查结果:床边12导联心电图提示窦性心律,II、III、aVF 导联 ST 段抬高,提示下壁急性心肌梗死可能。'
},
{
name: '胸部X线',
result: '检查结果:胸部X线未见明显气胸或纵隔明显增宽,心影大小基本正常,不能排除急性冠脉综合征。'
},
{
name: '心脏超声',
result: '检查结果:心脏超声提示左室下壁节段性运动减低,未见大量心包积液,需结合心电图及心肌标志物判断。'
},
{
name: '冠脉CTA',
result: '检查结果:冠脉CTA提示右冠状动脉近段重度狭窄/闭塞可能,建议结合急诊介入评估。'
}
]
const physicalForm = reactive({
temperature: '',
pulse: '',
respiration: '',
bloodPressure: '',
spo2: '',
complexion: '',
examFinding: '',
otherFinding: ''
})
const activeCase = computed(() => props.caseItem || storedCase.value)
const complaintShort = computed(() => {
@@ -342,30 +303,93 @@ async function handleCompleteInquiry() {
}
}
function openExamPanel() {
async function openExamPanel() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('未找到当前会话,请先生成模拟场景')
return
}
physicalPanelVisible.value = false
examPanelVisible.value = true
if (auxiliaryExams.value.length > 0 || auxiliaryExamsLoading.value) return
auxiliaryExamsLoading.value = true
try {
auxiliaryExams.value = await fetchAuxiliaryExamItems(sessionId)
} catch (error) {
showToast(error instanceof Error ? error.message : '辅助检查列表加载失败')
} finally {
auxiliaryExamsLoading.value = false
}
}
function openPhysicalPanel() {
async function openPhysicalPanel() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('未找到当前会话,请先生成模拟场景')
return
}
examPanelVisible.value = false
physicalPanelVisible.value = true
if (physicalExams.value.length > 0 || physicalExamsLoading.value) return
physicalExamsLoading.value = true
try {
physicalExams.value = await fetchPhysicalExamItems(sessionId)
} catch (error) {
showToast(error instanceof Error ? error.message : '体格检查列表加载失败')
} finally {
physicalExamsLoading.value = false
}
}
function selectAuxiliaryExam(exam: AuxiliaryExam) {
examPanelVisible.value = false
async function selectPhysicalExam(exam: ExamItem) {
const sessionId = activeSessionId.value
if (!sessionId || examOrdering.value) return
examOrdering.value = true
try {
const result = await orderPhysicalExamResult(sessionId, exam.item_code)
physicalPanelVisible.value = false
appendExamResultMessages('体格检查', result)
} catch (error) {
showToast(error instanceof Error ? error.message : '体格检查结果获取失败')
} finally {
examOrdering.value = false
}
}
async function selectAuxiliaryExam(exam: ExamItem) {
const sessionId = activeSessionId.value
if (!sessionId || examOrdering.value) return
examOrdering.value = true
try {
const result = await orderAuxiliaryExamResult(sessionId, exam.item_code)
examPanelVisible.value = false
appendExamResultMessages('辅助检查', result)
} catch (error) {
showToast(error instanceof Error ? error.message : '辅助检查结果获取失败')
} finally {
examOrdering.value = false
}
}
function appendExamResultMessages(kindLabel: string, result: ExamResult) {
const timestamp = Date.now()
const messages: ChatMessage[] = [
{
id: `doctor-exam-${timestamp}`,
role: 'doctor',
content: `选择辅助检查${exam.name}`,
content: `选择${kindLabel}${result.item_name}`,
label: '我'
},
{
id: `mentor-exam-${timestamp + 1}`,
role: 'mentor',
content: exam.result,
content: formatExamResult(result),
label: 'AI助手'
}
]
@@ -374,73 +398,14 @@ function selectAuxiliaryExam(exam: AuxiliaryExam) {
scrollToBottom()
}
function submitPhysicalExam() {
const items = [
physicalForm.temperature.trim() ? `体温 ${physicalForm.temperature.trim()}` : '',
physicalForm.pulse.trim() ? `心率 ${physicalForm.pulse.trim()}次/分` : '',
physicalForm.respiration.trim() ? `呼吸 ${physicalForm.respiration.trim()}次/分` : '',
physicalForm.bloodPressure.trim() ? `血压 ${physicalForm.bloodPressure.trim()}mmHg` : '',
physicalForm.spo2.trim() ? `血氧 ${physicalForm.spo2.trim()}%` : '',
physicalForm.complexion.trim() ? `意识/面色:${physicalForm.complexion.trim()}` : '',
physicalForm.examFinding.trim() ? `心肺/腹部查体:${physicalForm.examFinding.trim()}` : '',
physicalForm.otherFinding.trim() ? `其他发现:${physicalForm.otherFinding.trim()}` : ''
function formatExamResult(result: ExamResult) {
const flags = [
result.is_abnormal ? '异常' : '',
result.is_key ? '关键检查' : '',
result.already_ordered ? '已检查过' : ''
].filter(Boolean)
if (items.length === 0) {
showToast('请至少录入一项体格检查')
return
}
const timestamp = Date.now()
const summary = items.join('')
const messages: ChatMessage[] = [
{
id: `doctor-physical-${timestamp}`,
role: 'doctor',
content: `录入体格检查:${summary}`,
label: '我'
},
{
id: `mentor-physical-${timestamp + 1}`,
role: 'mentor',
content: buildPhysicalFeedback(),
label: 'AI助手'
}
]
session.messages.push(...messages)
physicalPanelVisible.value = false
resetPhysicalForm()
scrollToBottom()
}
function resetPhysicalForm() {
physicalForm.temperature = ''
physicalForm.pulse = ''
physicalForm.respiration = ''
physicalForm.bloodPressure = ''
physicalForm.spo2 = ''
physicalForm.complexion = ''
physicalForm.examFinding = ''
physicalForm.otherFinding = ''
}
function buildPhysicalFeedback() {
const pulse = Number(physicalForm.pulse)
const respiration = Number(physicalForm.respiration)
const spo2 = Number(physicalForm.spo2)
const temperature = Number(physicalForm.temperature)
const tips: string[] = []
if (pulse >= 100) tips.push('心率偏快')
if (respiration >= 22) tips.push('呼吸频率偏快')
if (spo2 > 0 && spo2 < 95) tips.push('血氧偏低')
if (temperature >= 37.3) tips.push('体温偏高')
if (physicalForm.complexion.includes('苍白') || physicalForm.complexion.includes('出汗')) tips.push('面色/出汗提示急性病容')
if (physicalForm.otherFinding.includes('血压差') || physicalForm.otherFinding.includes('双侧')) tips.push('双侧血压或脉搏差异需警惕主动脉夹层')
const prefix = tips.length ? `已记录体格检查。当前提示:${tips.join('、')}` : '已记录体格检查,暂未见明确异常体征。'
return `${prefix}建议结合胸痛性质、心电图及心肌标志物进一步判断,并持续监测生命体征变化。`
const suffix = flags.length ? `${flags.join('')}` : ''
return `${result.item_name}${suffix}${result.result_text || '暂无结果。'}`
}
function handleSend() {
@@ -1283,6 +1248,21 @@ page {
background: #f2f3fb;
}
.exam-empty {
min-height: 120px;
padding: 24px;
border: 1px dashed rgba(194, 198, 212, 0.7);
border-radius: 12px;
background: rgba(249, 249, 255, 0.75);
color: rgba(66, 71, 82, 0.78);
font-size: 14px;
line-height: 22px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.exam-name {
color: #191c21;
font-size: 18px;