feat: 新增体格检查

This commit is contained in:
王天骄
2026-06-03 16:01:03 +08:00
parent 0650ef1c4a
commit 287812fea5
5 changed files with 488 additions and 7 deletions
+476 -3
View File
@@ -88,8 +88,8 @@
<view class="input-panel">
<scroll-view class="quick-actions" scroll-x>
<view class="quick-row">
<button class="quick-chip" @click="sendQuickAction('体格检查')">体格检查</button>
<button class="quick-chip" @click="sendQuickAction('辅助检查')">辅助检查</button>
<button class="quick-chip" @click="openPhysicalPanel">体格检查</button>
<button class="quick-chip" @click="openExamPanel">辅助检查</button>
</view>
</scroll-view>
<view class="input-row">
@@ -108,6 +108,85 @@
</view>
</view>
<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>
<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>
<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>
<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>
</view>
</view>
</view>
<view v-if="examPanelVisible" class="exam-mask" @click="examPanelVisible = false">
<view class="exam-panel" @click.stop>
<view class="exam-header">
<text class="exam-title">选择辅助检查</text>
<button class="exam-close" aria-label="关闭" @click="examPanelVisible = false">
<view class="close-icon"></view>
</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>
</view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
@@ -115,7 +194,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { createMockChatSession, sendMockChatMessage, type ChatSession } from '../../api/chat'
import { createMockChatSession, sendMockChatMessage, type ChatMessage, type ChatSession } from '../../api/chat'
import DiagnosisPage from '../diagnosis/diagnosis.vue'
const props = defineProps<{
@@ -150,9 +229,46 @@ const scrollTop = ref(0)
const toastMessage = ref('')
const toastVisible = ref(false)
const showDiagnosisPage = ref(false)
const examPanelVisible = ref(false)
const physicalPanelVisible = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | 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 complaintShort = computed(() => {
if (session.patient.chiefComplaint.includes('胸痛')) return '胸痛'
return session.patient.chiefComplaint.slice(0, 6)
@@ -172,6 +288,107 @@ function sendQuickAction(content: string) {
handleSend()
}
function openExamPanel() {
physicalPanelVisible.value = false
examPanelVisible.value = true
}
function openPhysicalPanel() {
examPanelVisible.value = false
physicalPanelVisible.value = true
}
function selectAuxiliaryExam(exam: AuxiliaryExam) {
examPanelVisible.value = false
const timestamp = Date.now()
const messages: ChatMessage[] = [
{
id: `doctor-exam-${timestamp}`,
role: 'doctor',
content: `选择辅助检查:${exam.name}`,
label: '我'
},
{
id: `mentor-exam-${timestamp + 1}`,
role: 'mentor',
content: exam.result,
label: 'AI助手'
}
]
session.messages.push(...messages)
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()}` : ''
].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}建议结合胸痛性质、心电图及心肌标志物进一步判断,并持续监测生命体征变化。`
}
function handleSend() {
const content = draft.value.trim()
if (!content || sending.value) return
@@ -774,6 +991,262 @@ page {
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M2%2021 23%2012 2%203v7l15%202-15%202v7z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.exam-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 120;
background: rgba(25, 28, 33, 0.36);
display: flex;
align-items: flex-end;
justify-content: center;
}
.exam-panel {
width: 100%;
max-width: 448px;
max-height: 62vh;
border-radius: 20px 20px 0 0;
background: #ffffff;
box-shadow: 0 -12px 30px rgba(25, 28, 33, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.physical-panel {
width: 100%;
max-width: 448px;
max-height: 78vh;
border-radius: 20px 20px 0 0;
background: #ffffff;
box-shadow: 0 -12px 30px rgba(25, 28, 33, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.exam-header {
box-sizing: border-box;
flex: 0 0 auto;
height: 64px;
padding: 0 20px;
border-bottom: 1px solid #e7e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.exam-title {
color: #191c21;
font-size: 20px;
line-height: 28px;
font-weight: 700;
}
.exam-close {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.exam-close::after,
.exam-item::after {
border: 0;
}
.close-icon {
position: relative;
width: 24px;
height: 24px;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
left: 3px;
top: 11px;
width: 20px;
height: 2px;
border-radius: 999px;
background: #2e3037;
}
.close-icon::before {
transform: rotate(45deg);
}
.close-icon::after {
transform: rotate(-45deg);
}
.exam-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.exam-item {
box-sizing: border-box;
width: 100%;
height: 76px;
padding: 0 20px;
border-bottom: 1px solid #e7e8f0;
border-radius: 0;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
}
.exam-item:active {
background: #f2f3fb;
}
.exam-name {
color: #191c21;
font-size: 18px;
line-height: 26px;
font-weight: 500;
}
.chevron-icon {
width: 13px;
height: 13px;
border-top: 2px solid #191c21;
border-right: 2px solid #191c21;
transform: rotate(45deg);
}
.physical-form {
flex: 1;
min-height: 0;
padding: 16px 20px;
background: #f9f9ff;
}
.vital-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-field {
position: relative;
box-sizing: border-box;
min-height: 72px;
padding: 10px 12px;
border: 1px solid #c2c6d4;
border-radius: 12px;
background: #ffffff;
display: flex;
align-items: flex-end;
}
.form-field.full,
.form-field.textarea-field {
margin-top: 12px;
}
.field-label {
position: absolute;
left: 12px;
top: 8px;
color: #424752;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.field-input {
flex: 1;
min-width: 0;
height: 32px;
padding: 12px 4px 0 0;
border: 0;
background: transparent;
color: #191c21;
font-size: 16px;
line-height: 24px;
}
.field-unit {
flex: 0 0 auto;
margin-left: 4px;
color: #727783;
font-size: 12px;
line-height: 24px;
font-weight: 600;
}
.field-placeholder {
color: rgba(114, 119, 131, 0.55);
}
.textarea-field {
min-height: 104px;
align-items: stretch;
}
.field-textarea {
width: 100%;
min-height: 76px;
margin-top: 18px;
padding: 0;
border: 0;
background: transparent;
color: #191c21;
font-size: 15px;
line-height: 22px;
}
.physical-actions {
flex: 0 0 auto;
padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #e7e8f0;
background: #ffffff;
display: flex;
gap: 12px;
}
.physical-cancel,
.physical-submit {
flex: 1;
height: 48px;
border-radius: 12px;
font-size: 16px;
line-height: 48px;
font-weight: 700;
}
.physical-cancel::after,
.physical-submit::after {
border: 0;
}
.physical-cancel {
border: 1px solid rgba(0, 71, 141, 0.22);
background: #ffffff;
color: #00478d;
}
.physical-submit {
background: #00478d;
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 71, 141, 0.18);
}
.toast {
position: fixed;
left: 50%;