This commit is contained in:
Your Name
2026-04-07 18:13:03 +08:00
parent a780356908
commit fdf714f833
397 changed files with 15086 additions and 1043 deletions
+194 -41
View File
@@ -86,17 +86,31 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="患者" min-width="150">
<el-table-column label="ID" min-width="50">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="患者" min-width="60">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.patient_name }}</div>
<div class="patient-meta">{{ row.id }} · {{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label=" 性别 / 年龄" width="100" align="left">
<template #default="{ row }">
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}</div>
</template>
</el-table-column>
<el-table-column label="挂号" min-width="180">
<template #default="{ row }">
<div
@@ -104,9 +118,23 @@
:class="appointmentCellClasses(row)"
>
<template v-if="row.has_appointment">
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
<template v-if="appointmentRows(row).length">
<div
v-for="apt in appointmentRows(row)"
:key="apt.id || `${apt.doctor_id}-${apt.time_text}`"
class="apt-item"
:class="appointmentItemClass(apt)"
>
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
<div class="apt-time">{{ apt.time_text || '-' }}</div>
</div>
</template>
<template v-else>
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
</template>
</template>
<span v-else class="apt-none">未挂号</span>
</div>
@@ -118,14 +146,22 @@
<span v-else class="status-unconfirmed">未确认</span>
</template>
</el-table-column>
<el-table-column label="诊" min-width="130">
<el-table-column label="诊" min-width="150">
<template #default="{ row }">
<div class="diagnosis-cell">
<span class="diagnosis-date">{{ row.diagnosis_date_text || '-' }}</span>
<template v-if="getDictLabel(diagnosisTypeOptions, row.diagnosis_type) || getDictLabel(syndromeTypeOptions, row.syndrome_type)">
<span class="diagnosis-extra">{{ [getDictLabel(diagnosisTypeOptions, row.diagnosis_type), getDictLabel(syndromeTypeOptions, row.syndrome_type)].filter(Boolean).join(' · ') }}</span>
</template>
<div v-if="row.has_prescription" class="followup-cell">
<div class="followup-time">{{ row.followup_time_text || '' }}</div>
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
<el-tag
v-if="row.followup_rx_voided"
type="info"
size="small"
effect="plain"
class="followup-void-tag"
>
处方已作废
</el-tag>
</div>
<span v-else class="followup-none"></span>
</template>
</el-table-column>
<el-table-column label="助理" width="72" show-overflow-tooltip>
@@ -231,6 +267,7 @@
<img :src="qrcodeUrl" alt="小程序二维码" class="w-64 h-64 border border-gray-200 rounded" />
<div class="mt-4 text-sm text-gray-600">
<div>患者{{ currentQRCodePatient?.patient_name }}</div>
<div class="mt-2">挂号时间{{ qrcodeAppointmentTimeText }}</div>
<div class="mt-2">请使用企业微信扫描二维码然后转发给患者</div>
</div>
</div>
@@ -306,7 +343,7 @@
label-width="100px"
>
<el-form-item label="患者">
<span class="font-medium">{{ orderForm.patient_name }} ({{ orderForm.phone }})</span>
<span class="font-medium">{{ orderForm.patient_name }} ({{ maskPhone(orderForm.phone) }})</span>
</el-form-item>
<el-form-item label="订单类型" prop="order_type">
@@ -386,7 +423,7 @@
<div v-if="wechatContactInfo" class="mb-4">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="患者姓名">{{ wechatCurrentPatient?.patient_name }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ wechatContactInfo.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ maskPhone(wechatContactInfo.phone) || '-' }}</el-descriptions-item>
<el-descriptions-item label="企微联系人ID">
<span v-if="wechatContactInfo.external_userid">{{ wechatContactInfo.external_userid }}</span>
<el-tag v-else type="info" size="small">未关联</el-tag>
@@ -496,7 +533,7 @@ import { hasPermission } from '@/utils/perm'
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { useRoute } from 'vue-router'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
@@ -512,6 +549,23 @@ const formatDateTime = (timestamp: number | string) => {
return dayjs(ts).format('YYYY-MM-DD HH:mm:ss')
}
// 手机号脱敏
const maskPhone = (phone: string) => {
if (!phone || phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 身份证号脱敏
const maskIdCard = (idCard: string) => {
if (!idCard) return idCard
if (idCard.length === 15) {
return idCard.replace(/(\d{6})\d{5}(\d{4})/, '$1*****$2')
} else if (idCard.length === 18) {
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
}
return idCard
}
const formData = reactive({
patient_name: '',
diagnosis_type: '',
@@ -832,7 +886,9 @@ const orderForm = reactive({
phone: '',
order_type: '' as string | number,
amount: 0,
remark: ''
remark: '',
has_appointment: false,
appointment_time_text: ''
})
const orderRules = {
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
@@ -846,6 +902,8 @@ const handleCreateOrder = (row: any) => {
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
orderForm.has_appointment = !!row.has_appointment
orderForm.appointment_time_text = row.appointment_time_text || ''
orderFormRef.value?.clearValidate()
createOrderVisible.value = true
}
@@ -857,6 +915,8 @@ const resetOrderForm = () => {
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
orderForm.has_appointment = false
orderForm.appointment_time_text = ''
orderFormRef.value?.clearValidate()
}
@@ -878,7 +938,11 @@ const submitOrder = async () => {
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
currentQRCodePatient.value = { patient_name: orderForm.patient_name }
currentQRCodePatient.value = {
patient_name: orderForm.patient_name,
has_appointment: orderForm.has_appointment,
appointment_time_text: orderForm.appointment_time_text
}
try {
const qrRes = await generateOrderQrcode({ order_no: orderNo })
if (qrRes?.qrcode_url) {
@@ -904,6 +968,13 @@ const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
const currentQRCodePatient = ref<any>(null)
/** 二维码弹窗展示的挂号时间(与列表「挂号」列一致) */
const qrcodeAppointmentTimeText = computed(() => {
const p = currentQRCodePatient.value
if (!p) return '—'
if (!p.has_appointment) return '未挂号'
return p.appointment_time_text || '—'
})
const qrcodeDialogTitle = ref('小程序二维码')
const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
@@ -1021,6 +1092,35 @@ const appointmentStatusLabel = (row: any) => {
return '已挂号'
}
const appointmentStatusLabelByStatus = (status: number | string) => {
const s = Number(status)
if (s === 3) return '已完成'
if (s === 4) return '已过号'
return '已挂号'
}
const appointmentRows = (row: any) => {
const list = Array.isArray(row.appointments) ? row.appointments : []
if (list.length > 0) return list
if (!row.has_appointment) return []
return [{
id: row.appointment_id,
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
}]
}
const appointmentItemClass = (apt: any) => {
const s = Number(apt?.status)
return {
'apt-item-done': s === 3,
'apt-item-missed': s === 4,
'apt-item-active': s === 1
}
}
const appointmentCellClasses = (row: any) => {
const s = Number(row.appointment_status)
return {
@@ -1540,21 +1640,28 @@ onUnmounted(() => {
}
}
.patient-meta-col {
font-size: 12px;
color: var(--el-text-color-placeholder);
line-height: 1.4;
}
.appointment-cell {
padding: 4px 8px;
border-radius: 6px;
min-height: 36px;
&.has-apt {
background: linear-gradient(135deg, rgba(var(--el-color-success-rgb), 0.12), rgba(var(--el-color-success-rgb), 0.06));
border: 1px solid rgba(var(--el-color-success-rgb), 0.3);
.apt-item + .apt-item {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.apt-item {
.apt-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--el-color-success-dark-2);
background: var(--el-color-success-light-5);
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 2px;
@@ -1568,37 +1675,56 @@ onUnmounted(() => {
.apt-time {
font-size: 12px;
color: var(--el-color-success);
margin-top: 2px;
}
&.apt-item-active {
.apt-badge {
color: #3a4acc;
background: rgba(74, 93, 255, 0.15);
}
.apt-time {
color: #4A5DFF;
}
}
&.apt-item-done {
.apt-badge {
color: var(--el-color-info-dark-2);
background: var(--el-color-info-light-7);
}
.apt-time {
color: var(--el-color-info);
}
}
&.apt-item-missed {
.apt-badge {
color: var(--el-color-warning-dark-2);
background: var(--el-color-warning-light-7);
}
.apt-time {
color: var(--el-color-warning);
}
}
}
&.has-apt {
background: linear-gradient(135deg, rgba(74, 93, 255, 0.12), rgba(74, 93, 255, 0.06));
border: 1px solid rgba(74, 93, 255, 0.3);
}
&.apt-row-missed {
background: linear-gradient(135deg, rgba(var(--el-color-warning-rgb), 0.12), rgba(var(--el-color-warning-rgb), 0.05));
border-color: rgba(var(--el-color-warning-rgb), 0.35);
.apt-badge {
color: var(--el-color-warning-dark-2);
background: var(--el-color-warning-light-7);
}
.apt-time {
color: var(--el-color-warning);
}
}
&.apt-row-done {
background: linear-gradient(135deg, rgba(var(--el-color-info-rgb), 0.1), rgba(var(--el-color-info-rgb), 0.04));
border-color: rgba(var(--el-color-info-rgb), 0.28);
.apt-badge {
color: var(--el-color-info-dark-2);
background: var(--el-color-info-light-7);
}
.apt-time {
color: var(--el-color-info);
}
}
.apt-none {
@@ -1621,7 +1747,7 @@ onUnmounted(() => {
.status-prescribed {
font-size: 13px;
color: var(--el-color-success);
color: #4A5DFF;
font-weight: 500;
}
@@ -1647,6 +1773,33 @@ onUnmounted(() => {
}
}
.followup-cell {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.35;
.followup-time {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.followup-doctor {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.followup-void-tag {
align-self: flex-start;
}
}
.followup-none {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.assistant-cell {
font-size: 13px;
color: var(--el-text-color-secondary);