feat(mobile): 移动护理APP医嘱执行+生命体征

This commit is contained in:
2026-06-18 22:47:26 +08:00
parent aa4a582981
commit cb9968ee76
15 changed files with 1419 additions and 0 deletions

View File

@@ -129,6 +129,37 @@ export const constantRoutes = [
}
]
},
{
path: '/nursingmobile',
component: Layout,
hidden: true,
children: [
{
path: 'patient-list',
component: () => import('@/views/nursingmobile/PatientList.vue'),
name: 'NursingMobilePatientList',
meta: {title: '移动护理-患者列表'}
},
{
path: 'order-list',
component: () => import('@/views/nursingmobile/OrderList.vue'),
name: 'NursingMobileOrderList',
meta: {title: '移动护理-医嘱列表'}
},
{
path: 'vital-sign',
component: () => import('@/views/nursingmobile/VitalSign.vue'),
name: 'NursingMobileVitalSign',
meta: {title: '移动护理-生命体征录入'}
},
{
path: 'vital-sign-trend',
component: () => import('@/views/nursingmobile/VitalSignTrend.vue'),
name: 'NursingMobileVitalSignTrend',
meta: {title: '移动护理-体征趋势'}
}
]
},
// 添加套餐管理相关路由到公共路由,确保始终可用
{
path: '/maintainSystem/Inspection/PackageManagement',

View File

@@ -0,0 +1,245 @@
<template>
<div class="mobile-order-list">
<div class="page-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">{{ patientName }} ({{ bedName }})</span>
</template>
</el-page-header>
</div>
<div class="filter-bar">
<el-radio-group v-model="statusFilter" size="small" @change="fetchOrders">
<el-radio-button :value="2">执行中</el-radio-button>
<el-radio-button :value="10">已校对</el-radio-button>
<el-radio-button :value="null">全部</el-radio-button>
</el-radio-group>
<el-button type="primary" size="small" @click="handleScan">
扫码执行
</el-button>
</div>
<div v-loading="loading" class="order-list">
<div
v-for="order in orderList"
:key="order.requestId"
class="order-item"
>
<div class="order-header">
<span class="order-name">{{ order.adviceName }}</span>
<el-tag :type="getStatusType(order.requestStatus)" size="small">
{{ order.requestStatusText }}
</el-tag>
</div>
<div class="order-body">
<div class="info-row">
<span class="label">类型:</span>
<el-tag size="small">{{ order.therapyEnumText }}</el-tag>
</div>
<div v-if="order.frequencyUsage" class="info-row">
<span class="label">频次:</span>
<span class="value">{{ order.frequencyUsage }}</span>
</div>
<div v-if="order.singleDose" class="info-row">
<span class="label">剂量:</span>
<span class="value">{{ order.singleDose }}</span>
</div>
<div class="info-row">
<span class="label">开嘱医生:</span>
<span class="value">{{ order.requesterName }}</span>
</div>
</div>
<div class="order-footer">
<el-button
v-if="order.requestStatus === 2 || order.requestStatus === 10"
type="success"
size="small"
@click="handleExecute(order)"
>
执行
</el-button>
</div>
</div>
<el-empty v-if="!loading && orderList.length === 0" description="暂无医嘱" />
</div>
<el-dialog v-model="scanDialogVisible" title="扫码执行" width="400px">
<el-form :model="scanForm" label-width="80px">
<el-form-item label="条码">
<el-input v-model="scanForm.barcode" placeholder="请扫描或输入条码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scanDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmScan">确认执行</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMobileOrderList, executeOrder } from './api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const statusFilter = ref(2)
const orderList = ref([])
const scanDialogVisible = ref(false)
const scanForm = ref({ barcode: '' })
const patientName = ref(route.query.patientName || '')
const bedName = ref(route.query.bedName || '')
const patientId = ref(route.query.patientId)
const fetchOrders = async () => {
if (!patientId.value) return
loading.value = true
try {
const res = await getMobileOrderList(patientId.value, { statusFilter: statusFilter.value })
orderList.value = res.data || []
} finally {
loading.value = false
}
}
const goBack = () => {
router.push('/nursingmobile/patient-list')
}
const handleScan = () => {
scanForm.value.barcode = ''
scanDialogVisible.value = true
}
const confirmScan = () => {
if (!scanForm.value.barcode) {
ElMessage.warning('请输入条码')
return
}
const matchedOrder = orderList.value.find(o => o.barcode === scanForm.value.barcode)
if (matchedOrder) {
handleExecute(matchedOrder)
} else {
ElMessage.error('未找到匹配的医嘱')
}
scanDialogVisible.value = false
}
const handleExecute = async (order) => {
try {
await ElMessageBox.confirm(
`确认执行医嘱: ${order.adviceName}?`,
'确认执行',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
)
loading.value = true
await executeOrder({
requestId: order.requestId,
adviceTable: order.adviceTable,
encounterId: order.encounterId,
patientId: order.patientId
})
ElMessage.success('执行成功')
fetchOrders()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error('执行失败')
}
} finally {
loading.value = false
}
}
const getStatusType = (status) => {
const map = { 2: 'primary', 3: 'success', 6: 'info', 10: 'warning', 11: '' }
return map[status] || 'info'
}
onMounted(() => {
fetchOrders()
})
</script>
<style scoped>
.mobile-order-list {
padding: 16px;
}
.page-header {
margin-bottom: 16px;
}
.page-title {
font-size: 16px;
font-weight: 600;
}
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.order-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.order-item {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.order-name {
font-size: 15px;
font-weight: 600;
flex: 1;
margin-right: 8px;
}
.order-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
}
.info-row .label {
color: #666;
font-size: 13px;
min-width: 70px;
}
.info-row .value {
font-size: 14px;
}
.order-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="mobile-patient-list">
<div class="page-header">
<h2>患者列表</h2>
<el-input
v-model="searchKey"
placeholder="搜索姓名/床号"
clearable
style="width: 200px"
@input="handleSearch"
/>
</div>
<div v-loading="loading" class="patient-cards">
<div
v-for="patient in patientList"
:key="patient.encounterId"
class="patient-card"
@click="handlePatientClick(patient)"
>
<div class="card-header">
<span class="patient-name">{{ patient.patientName }}</span>
<el-tag :type="getGenderType(patient.genderEnum)" size="small">
{{ patient.genderEnumText }}
</el-tag>
</div>
<div class="card-body">
<div class="info-row">
<span class="label">床号:</span>
<span class="value">{{ patient.bedName || '-' }}</span>
</div>
<div class="info-row">
<span class="label">护理等级:</span>
<el-tag :type="getNursingLevelType(patient.nursingLevel)" size="small">
{{ patient.nursingLevelText }}
</el-tag>
</div>
<div class="info-row">
<span class="label">病情:</span>
<el-tag :type="getPriorityType(patient.priorityEnum)" size="small">
{{ patient.priorityEnumText }}
</el-tag>
</div>
<div v-if="patient.diagnosis" class="info-row">
<span class="label">诊断:</span>
<span class="value diagnosis">{{ patient.diagnosis }}</span>
</div>
</div>
<div class="card-footer">
<span class="doctor">{{ patient.admittingDoctorName }}</span>
</div>
</div>
<el-empty v-if="!loading && patientList.length === 0" description="暂无患者" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getMobilePatientList } from './api'
const router = useRouter()
const loading = ref(false)
const searchKey = ref('')
const patientList = ref([])
const fetchData = async () => {
loading.value = true
try {
const res = await getMobilePatientList({ searchKey: searchKey.value })
patientList.value = res.data || []
} finally {
loading.value = false
}
}
const handleSearch = () => {
fetchData()
}
const handlePatientClick = (patient) => {
router.push({
path: '/nursingmobile/order-list',
query: {
encounterId: patient.encounterId,
patientId: patient.patientId,
patientName: patient.patientName,
bedName: patient.bedName
}
})
}
const getGenderType = (gender) => {
return gender === 1 ? 'primary' : gender === 2 ? 'danger' : 'info'
}
const getNursingLevelType = (level) => {
const map = { 1: 'danger', 2: 'warning', 3: '', 4: 'danger' }
return map[level] || 'info'
}
const getPriorityType = (priority) => {
const map = { 1: 'danger', 2: 'warning', 3: '', 4: 'info' }
return map[priority] || 'info'
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.mobile-patient-list {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0;
font-size: 18px;
}
.patient-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.patient-card {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s;
}
.patient-card:active {
transform: scale(0.98);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.patient-name {
font-size: 16px;
font-weight: 600;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
}
.info-row .label {
color: #666;
font-size: 13px;
min-width: 60px;
}
.info-row .value {
font-size: 14px;
}
.info-row .diagnosis {
color: #e6a23c;
}
.card-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.doctor {
color: #999;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="mobile-vital-sign">
<div class="page-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">生命体征录入</span>
</template>
</el-page-header>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
class="vital-form"
>
<el-form-item label="患者">
<span class="patient-info">{{ patientName }} ({{ bedName }})</span>
</el-form-item>
<el-form-item label="记录时间">
<el-date-picker
v-model="form.recordDate"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="时点">
<el-select v-model="form.recordHour" placeholder="选择时点" style="width: 100%">
<el-option v-for="h in 24" :key="h-1" :label="(h-1)+':00'" :value="h-1" />
</el-select>
</el-form-item>
<el-form-item label="体温" prop="temperature">
<el-input-number v-model="form.temperature" :min="35" :max="42" :step="0.1" :precision="1" style="width: 100%" />
<span class="unit">°C</span>
</el-form-item>
<el-form-item label="脉搏" prop="pulse">
<el-input-number v-model="form.pulse" :min="40" :max="200" style="width: 100%" />
<span class="unit">/</span>
</el-form-item>
<el-form-item label="呼吸" prop="respiration">
<el-input-number v-model="form.respiration" :min="10" :max="60" style="width: 100%" />
<span class="unit">/</span>
</el-form-item>
<el-form-item label="收缩压" prop="systolicBp">
<el-input-number v-model="form.systolicBp" :min="60" :max="300" style="width: 100%" />
<span class="unit">mmHg</span>
</el-form-item>
<el-form-item label="舒张压" prop="diastolicBp">
<el-input-number v-model="form.diastolicBp" :min="30" :max="200" style="width: 100%" />
<span class="unit">mmHg</span>
</el-form-item>
<el-form-item label="疼痛评分">
<el-rate v-model="form.painScore" :max="10" show-score />
</el-form-item>
<el-form-item label="意识">
<el-select v-model="form.consciousLevel" placeholder="选择意识状态" style="width: 100%">
<el-option label="清醒" value="清醒" />
<el-option label="嗜睡" value="嗜睡" />
<el-option label="模糊" value="模糊" />
<el-option label="昏睡" value="昏睡" />
<el-option label="浅昏迷" value="浅昏迷" />
<el-option label="深昏迷" value="深昏迷" />
</el-select>
</el-form-item>
<el-form-item label="入量">
<el-input-number v-model="form.inputMl" :min="0" style="width: 100%" />
<span class="unit">ml</span>
</el-form-item>
<el-form-item label="出量">
<el-input-number v-model="form.outputMl" :min="0" style="width: 100%" />
<span class="unit">ml</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" style="width: 100%" @click="handleSubmit">
保存
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { saveVitalSign } from './api'
const route = useRoute()
const router = useRouter()
const formRef = ref(null)
const submitting = ref(false)
const patientName = ref(route.query.patientName || '')
const bedName = ref(route.query.bedName || '')
const patientId = ref(route.query.patientId)
const encounterId = ref(route.query.encounterId)
const now = new Date()
const form = reactive({
patientId: patientId.value ? Number(patientId.value) : null,
encounterId: encounterId.value ? Number(encounterId.value) : null,
patientName: patientName.value,
recordDate: now.toISOString().split('T')[0],
recordHour: now.getHours(),
temperature: null,
pulse: null,
respiration: null,
systolicBp: null,
diastolicBp: null,
painScore: 0,
consciousLevel: '清醒',
inputMl: null,
outputMl: null,
nurseName: ''
})
const rules = {
temperature: [{ required: true, message: '请输入体温', trigger: 'blur' }],
pulse: [{ required: true, message: '请输入脉搏', trigger: 'blur' }],
systolicBp: [{ required: true, message: '请输入收缩压', trigger: 'blur' }],
diastolicBp: [{ required: true, message: '请输入舒张压', trigger: 'blur' }]
}
const goBack = () => {
router.push('/nursingmobile/patient-list')
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
submitting.value = true
await saveVitalSign(form)
ElMessage.success('保存成功')
router.push('/nursingmobile/vital-sign-trend?patientId=' + patientId.value + '&patientName=' + patientName.value)
} catch (e) {
if (e !== false) {
ElMessage.error('保存失败')
}
} finally {
submitting.value = false
}
}
onMounted(() => {})
</script>
<style scoped>
.mobile-vital-sign {
padding: 16px;
}
.page-header {
margin-bottom: 16px;
}
.page-title {
font-size: 16px;
font-weight: 600;
}
.vital-form {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.patient-info {
font-size: 15px;
font-weight: 600;
color: #409eff;
}
.unit {
margin-left: 8px;
color: #999;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="mobile-vital-trend">
<div class="page-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">体征趋势</span>
</template>
</el-page-header>
</div>
<div class="patient-info">
<span>{{ patientName }}</span>
</div>
<div class="days-filter">
<el-radio-group v-model="days" size="small" @change="fetchTrend">
<el-radio-button :value="3">3</el-radio-button>
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="14">14</el-radio-button>
</el-radio-group>
</div>
<div v-loading="loading" class="trend-charts">
<div class="chart-section">
<h4>体温 (°C)</h4>
<div class="chart-container">
<div v-if="trendData.temperatureData.length === 0" class="no-data">暂无数据</div>
<div v-else class="simple-chart">
<div v-for="(point, idx) in trendData.temperatureData" :key="idx" class="chart-point">
<div class="point-value">{{ point.value }}</div>
<div class="point-bar" :style="{ height: getBarHeight(point.value, 35, 42) + 'px' }" />
<div class="point-label">{{ formatLabel(point.label) }}</div>
</div>
</div>
</div>
</div>
<div class="chart-section">
<h4>脉搏 (/)</h4>
<div class="chart-container">
<div v-if="trendData.pulseData.length === 0" class="no-data">暂无数据</div>
<div v-else class="simple-chart">
<div v-for="(point, idx) in trendData.pulseData" :key="idx" class="chart-point">
<div class="point-value">{{ point.value }}</div>
<div class="point-bar pulse" :style="{ height: getBarHeight(point.value, 40, 120) + 'px' }" />
<div class="point-label">{{ formatLabel(point.label) }}</div>
</div>
</div>
</div>
</div>
<div class="chart-section">
<h4>血压 (mmHg)</h4>
<div class="chart-container">
<div v-if="trendData.systolicBpData.length === 0" class="no-data">暂无数据</div>
<div v-else class="simple-chart">
<div v-for="(point, idx) in trendData.systolicBpData" :key="idx" class="chart-point">
<div class="point-value">{{ point.value }}/{{ getDiastolicValue(idx) }}</div>
<div class="point-bar bp" :style="{ height: getBarHeight(point.value, 60, 200) + 'px' }" />
<div class="point-label">{{ formatLabel(point.label) }}</div>
</div>
</div>
</div>
</div>
<div class="chart-section">
<h4>呼吸 (/)</h4>
<div class="chart-container">
<div v-if="trendData.respirationData.length === 0" class="no-data">暂无数据</div>
<div v-else class="simple-chart">
<div v-for="(point, idx) in trendData.respirationData" :key="idx" class="chart-point">
<div class="point-value">{{ point.value }}</div>
<div class="point-bar resp" :style="{ height: getBarHeight(point.value, 10, 40) + 'px' }" />
<div class="point-label">{{ formatLabel(point.label) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getVitalSignTrend } from './api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const days = ref(7)
const patientName = ref(route.query.patientName || '')
const patientId = ref(route.query.patientId)
const trendData = ref({
temperatureData: [],
pulseData: [],
systolicBpData: [],
diastolicBpData: [],
respirationData: []
})
const fetchTrend = async () => {
if (!patientId.value) return
loading.value = true
try {
const res = await getVitalSignTrend(patientId.value, { days: days.value })
trendData.value = res.data || {
temperatureData: [],
pulseData: [],
systolicBpData: [],
diastolicBpData: [],
respirationData: []
}
if (res.data?.patientName) {
patientName.value = res.data.patientName
}
} finally {
loading.value = false
}
}
const goBack = () => {
router.push('/nursingmobile/patient-list')
}
const getBarHeight = (value, min, max) => {
if (!value) return 0
const normalized = (value - min) / (max - min)
return Math.max(10, Math.min(80, normalized * 80))
}
const getDiastolicValue = (idx) => {
const point = trendData.value.diastolicBpData[idx]
return point ? point.value : '-'
}
const formatLabel = (label) => {
if (!label) return ''
const parts = label.split(' ')
return parts.length > 1 ? parts[1] : label
}
onMounted(() => {
fetchTrend()
})
</script>
<style scoped>
.mobile-vital-trend {
padding: 16px;
}
.page-header {
margin-bottom: 16px;
}
.page-title {
font-size: 16px;
font-weight: 600;
}
.patient-info {
font-size: 15px;
font-weight: 600;
color: #409eff;
margin-bottom: 16px;
}
.days-filter {
margin-bottom: 16px;
}
.trend-charts {
display: flex;
flex-direction: column;
gap: 20px;
}
.chart-section {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.chart-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #333;
}
.chart-container {
min-height: 100px;
}
.no-data {
text-align: center;
color: #999;
padding: 40px 0;
}
.simple-chart {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.chart-point {
display: flex;
flex-direction: column;
align-items: center;
min-width: 50px;
}
.point-value {
font-size: 11px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.point-bar {
width: 20px;
background: #409eff;
border-radius: 4px 4px 0 0;
min-height: 10px;
}
.point-bar.pulse {
background: #67c23a;
}
.point-bar.bp {
background: #e6a23c;
}
.point-bar.resp {
background: #909399;
}
.point-label {
font-size: 10px;
color: #999;
margin-top: 4px;
text-align: center;
writing-mode: vertical-rl;
max-height: 60px;
}
</style>

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function getMobilePatientList(params) {
return request({ url: '/nursing/mobile/patient-list', method: 'get', params })
}
export function getMobileOrderList(patientId, params) {
return request({ url: '/nursing/mobile/order-list/' + patientId, method: 'get', params })
}
export function executeOrder(data) {
return request({ url: '/nursing/mobile/order-execute', method: 'post', data })
}
export function saveVitalSign(data) {
return request({ url: '/nursing/mobile/vital-sign', method: 'post', data })
}
export function getVitalSignTrend(patientId, params) {
return request({ url: '/nursing/mobile/vital-sign-trend/' + patientId, method: 'get', params })
}