1139 lines
35 KiB
Vue
1139 lines
35 KiB
Vue
<template>
|
||
<div class="my-patients-page">
|
||
<div class="page-heading">
|
||
<div>
|
||
<div class="eyebrow">一诊</div>
|
||
<h1>我的患者</h1>
|
||
<p>患者、挂号与诊单信息按当前角色和部门数据范围展示</p>
|
||
</div>
|
||
<div class="heading-actions">
|
||
<el-tag effect="plain" round>{{ scopeLabel }}</el-tag>
|
||
<el-button :icon="Refresh" :loading="workspaceLoading" @click="refreshPage">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-card class="workspace-card" shadow="never">
|
||
<el-tabs v-model="activeWorkspace" class="workspace-tabs">
|
||
<el-tab-pane label="患者列表" name="patients" />
|
||
<el-tab-pane label="订单管理" name="orders" />
|
||
<el-tab-pane label="面诊进度" name="progress" />
|
||
</el-tabs>
|
||
|
||
<div v-show="activeWorkspace === 'patients'">
|
||
<div class="filter-panel">
|
||
<div class="filter-row filter-row-main">
|
||
<el-input
|
||
v-model="formData.keyword"
|
||
class="keyword-input"
|
||
clearable
|
||
:prefix-icon="Search"
|
||
placeholder="患者姓名 / 手机号 / 助理 / 医生"
|
||
@keyup.enter="handleSearch"
|
||
@clear="handleSearch"
|
||
/>
|
||
<div class="filter-group status-group">
|
||
<span class="filter-label">状态</span>
|
||
<el-radio-group v-model="formData.status_filter" @change="handleFilterChange">
|
||
<el-radio-button v-for="item in statusOptions" :key="item.value" :value="item.value">
|
||
{{ item.label }}
|
||
</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</div>
|
||
|
||
<div class="filter-row date-filter-row">
|
||
<span class="filter-label">挂号时间</span>
|
||
<div class="quick-dates">
|
||
<el-button
|
||
v-for="item in dateOptions"
|
||
:key="item.value"
|
||
:type="activeDateType === item.value ? 'primary' : 'default'"
|
||
@click="selectDateType(item.value)"
|
||
>
|
||
{{ item.label }}
|
||
</el-button>
|
||
</div>
|
||
<el-date-picker
|
||
v-model="customDateRange"
|
||
class="date-range"
|
||
type="daterange"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
value-format="YYYY-MM-DD"
|
||
:clearable="true"
|
||
@change="handleCustomDateChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary-grid">
|
||
<button class="summary-card summary-today" type="button" @click="selectDateType('today')">
|
||
<span class="summary-icon"><el-icon><Calendar /></el-icon></span>
|
||
<span class="summary-copy"><small>今日挂号</small><strong>{{ summary.today }}</strong><em>人</em></span>
|
||
<span class="summary-date">{{ summaryDates.today || '—' }}</span>
|
||
</button>
|
||
<button class="summary-card summary-tomorrow" type="button" @click="selectDateType('tomorrow')">
|
||
<span class="summary-icon"><el-icon><Clock /></el-icon></span>
|
||
<span class="summary-copy"><small>明日预约</small><strong>{{ summary.tomorrow }}</strong><em>人</em></span>
|
||
<span class="summary-date">{{ summaryDates.tomorrow || '—' }}</span>
|
||
</button>
|
||
<button class="summary-card summary-after" type="button" @click="selectDateType('day_after')">
|
||
<span class="summary-icon"><el-icon><Calendar /></el-icon></span>
|
||
<span class="summary-copy"><small>后天预约</small><strong>{{ summary.day_after }}</strong><em>人</em></span>
|
||
<span class="summary-date">{{ summaryDates.day_after || '—' }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="table-heading">
|
||
<div>
|
||
<h2>患者列表</h2>
|
||
<span>共 {{ pager.count }} 位患者</span>
|
||
</div>
|
||
<span class="table-scope"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="pager.loading"
|
||
:data="pager.lists"
|
||
class="patient-table"
|
||
:row-class-name="tableRowClassName"
|
||
@row-dblclick="openDiagnosis"
|
||
>
|
||
<el-table-column label="患者" min-width="190" fixed="left">
|
||
<template #default="{ row }">
|
||
<div class="patient-cell">
|
||
<span class="patient-avatar">{{ patientInitial(row.patient_name) }}</span>
|
||
<div>
|
||
<strong>{{ row.patient_name || '未命名患者' }}</strong>
|
||
<p>{{ row.gender_desc }} · {{ row.age || '—' }}岁 · {{ row.phone_masked || '无手机号' }}</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="assistant_name" label="归属助理" min-width="120" />
|
||
<el-table-column label="预约医生" min-width="150">
|
||
<template #default="{ row }">
|
||
<div class="doctor-cell">
|
||
<span>{{ row.appointment_doctor_name || '未预约' }}</span>
|
||
<el-tag v-if="row.appointment_status" size="small" :type="appointmentTagType(row.appointment_status)" effect="light">
|
||
{{ row.appointment_status_text }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="预约时间" min-width="165">
|
||
<template #default="{ row }">
|
||
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
||
{{ row.appointment_time_text || '暂无预约' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="复诊次数" width="105" align="center">
|
||
<template #default="{ row }">
|
||
<span class="revisit-count">{{ row.revisit_count || 0 }} 次</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="确认信息" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.confirmed ? 'success' : 'warning'" effect="light">
|
||
{{ row.confirmation_text }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="诊单日期" width="118">
|
||
<template #default="{ row }">{{ row.diagnosis_date_text || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="430" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="row-actions">
|
||
<el-button v-if="canEditDiagnosis" type="primary" link @click="openDiagnosis(row)">诊单</el-button>
|
||
<el-button v-else-if="canReadDiagnosis" type="primary" link @click="openReadonlyDiagnosis(row)">查看</el-button>
|
||
<el-button v-if="canBookAppointment" type="primary" link @click="openAppointment(row)">预约</el-button>
|
||
<el-button
|
||
v-if="canAssignPatient"
|
||
type="warning"
|
||
link
|
||
@click="openAssignDialog(row)"
|
||
>
|
||
{{ Number(row.assistant_id) > 0 ? '重新指派' : '指派' }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="canFillIdCard && !Number(row.has_id_card)"
|
||
type="warning"
|
||
link
|
||
@click="openFillIdCardDialog(row)"
|
||
>
|
||
补全身份证
|
||
</el-button>
|
||
<el-button
|
||
v-if="canShowDiagnosisQRCode(row)"
|
||
type="primary"
|
||
link
|
||
@click="openDiagnosisQRCode(row)"
|
||
>
|
||
诊单二维码
|
||
</el-button>
|
||
<el-button
|
||
v-if="canBookAppointment && canCancelAppointment(row)"
|
||
type="danger"
|
||
link
|
||
@click="cancelAppointmentForRow(row)"
|
||
>
|
||
取消挂号
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty>
|
||
<el-empty description="当前范围内暂无患者" />
|
||
</template>
|
||
</el-table>
|
||
|
||
<div class="pagination-wrap">
|
||
<pagination v-model="pager" @change="getLists" />
|
||
</div>
|
||
</div>
|
||
|
||
<order-panel
|
||
v-if="activeWorkspace === 'orders'"
|
||
ref="orderPanelRef"
|
||
@open-diagnosis="openDiagnosis"
|
||
/>
|
||
<progress-panel
|
||
v-if="activeWorkspace === 'progress'"
|
||
ref="progressPanelRef"
|
||
@open-diagnosis="openDiagnosis"
|
||
/>
|
||
</el-card>
|
||
|
||
<edit-popup ref="editRef" @success="refreshPage" />
|
||
<appointment-popup ref="appointmentRef" api-scene="my_patient" @success="refreshPage" />
|
||
|
||
<el-dialog
|
||
v-model="assignDialogVisible"
|
||
title="指派医助"
|
||
width="500px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-form :model="assignForm" label-width="88px">
|
||
<el-form-item label="患者">
|
||
<span class="dialog-patient-name">{{ currentActionPatient?.patient_name || '—' }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="当前助理">
|
||
<span>{{ currentActionPatient?.assistant_name || '未分配' }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="选择医助" required>
|
||
<el-select
|
||
v-model="assignForm.assistant_id"
|
||
class="dialog-full-width"
|
||
filterable
|
||
clearable
|
||
:loading="assistantOptionsLoading"
|
||
placeholder="请选择当前范围内的医助"
|
||
>
|
||
<el-option
|
||
v-for="item in assistantOptions"
|
||
:key="item.id"
|
||
:label="assistantOptionLabel(item)"
|
||
:value="Number(item.id)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="继承">
|
||
<div class="inherit-field">
|
||
<el-checkbox v-model="assignForm.is_inherit">作为继承指派</el-checkbox>
|
||
<small>用于区分继承关系,指派操作仍会完整记录。</small>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="assignLoading" @click="submitAssign">确定指派</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="fillIdCardDialogVisible"
|
||
title="补全身份证号"
|
||
width="430px"
|
||
:close-on-click-modal="false"
|
||
@closed="resetFillIdCardForm"
|
||
>
|
||
<el-form
|
||
ref="fillIdCardFormRef"
|
||
:model="fillIdCardForm"
|
||
:rules="fillIdCardRules"
|
||
label-width="88px"
|
||
>
|
||
<el-form-item label="患者">
|
||
<span class="dialog-patient-name">{{ fillIdCardForm.patient_name || '—' }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="身份证号" prop="id_card">
|
||
<el-input
|
||
v-model="fillIdCardForm.id_card"
|
||
maxlength="18"
|
||
clearable
|
||
show-word-limit
|
||
placeholder="请输入15或18位身份证号"
|
||
@keyup.enter="submitFillIdCard"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="fillIdCardDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="fillIdCardLoading" @click="submitFillIdCard">
|
||
提交并更新年龄
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="qrcodeDialogVisible"
|
||
title="诊单二维码"
|
||
width="400px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="qrcode-body">
|
||
<template v-if="qrcodeLoading">
|
||
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
|
||
<span>二维码生成中...</span>
|
||
</template>
|
||
<template v-else-if="qrcodeUrl">
|
||
<img :src="qrcodeUrl" alt="诊单二维码" class="qrcode-image" />
|
||
<div class="qrcode-meta">
|
||
<div>患者:{{ currentQRCodePatient?.patient_name || '—' }}</div>
|
||
<div>挂号时间:{{ currentQRCodePatient?.appointment_time_text || '—' }}</div>
|
||
<div>请使用企业微信扫描二维码,然后转发给患者</div>
|
||
</div>
|
||
</template>
|
||
<span v-else class="qrcode-error">生成失败,请重试</span>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="qrcodeDialogVisible = false">关闭</el-button>
|
||
<el-button v-if="!qrcodeLoading" type="primary" @click="regenerateDiagnosisQRCode">重新生成</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import dayjs from 'dayjs'
|
||
import { Calendar, Clock, Loading, Lock, Refresh, Search } from '@element-plus/icons-vue'
|
||
import { usePaging } from '@/hooks/usePaging'
|
||
import { hasPermission } from '@/utils/perm'
|
||
import feedback from '@/utils/feedback'
|
||
import {
|
||
myPatientAssign,
|
||
myPatientAssistants,
|
||
myPatientCancelAppointment,
|
||
myPatientFillIdCard,
|
||
myPatientLists
|
||
} from '@/api/first_visit'
|
||
import { generateMiniProgramQrcode } from '@/api/tcm'
|
||
import { getWeappConfig } from '@/api/channel/weapp'
|
||
import useUserStore from '@/stores/modules/user'
|
||
|
||
const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
|
||
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
|
||
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
|
||
|
||
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
|
||
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
|
||
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const activeWorkspace = ref('patients')
|
||
const activeDateType = ref<DateType>('all')
|
||
const customDateRange = ref<string[]>([])
|
||
const editRef = ref<any>()
|
||
const appointmentRef = ref<any>()
|
||
const orderPanelRef = ref<any>()
|
||
const progressPanelRef = ref<any>()
|
||
const qrcodeDialogVisible = ref(false)
|
||
const qrcodeLoading = ref(false)
|
||
const qrcodeUrl = ref('')
|
||
const currentQRCodePatient = ref<any>(null)
|
||
const assignDialogVisible = ref(false)
|
||
const assignLoading = ref(false)
|
||
const assistantOptionsLoading = ref(false)
|
||
const assistantOptions = ref<any[]>([])
|
||
const currentActionPatient = ref<any>(null)
|
||
const assignForm = reactive({
|
||
assistant_id: null as number | null,
|
||
is_inherit: false
|
||
})
|
||
const fillIdCardDialogVisible = ref(false)
|
||
const fillIdCardLoading = ref(false)
|
||
const fillIdCardFormRef = ref<any>()
|
||
const fillIdCardForm = reactive({
|
||
id: 0,
|
||
patient_name: '',
|
||
id_card: ''
|
||
})
|
||
|
||
const formData = reactive({
|
||
keyword: '',
|
||
status_filter: '' as StatusFilter,
|
||
start_date: '',
|
||
end_date: ''
|
||
})
|
||
|
||
const statusOptions: Array<{ label: string; value: StatusFilter }> = [
|
||
{ label: '全部', value: '' },
|
||
{ label: '未预约', value: 'unbooked' },
|
||
{ label: '待面诊', value: 'pending_interview' },
|
||
{ label: '已完成', value: 'completed' },
|
||
{ label: '已过号', value: 'missed' }
|
||
]
|
||
|
||
const dateOptions: Array<{ label: string; value: DateType }> = [
|
||
{ label: '全部时间', value: 'all' },
|
||
{ label: '今日挂号', value: 'today' },
|
||
{ label: '明日', value: 'tomorrow' },
|
||
{ label: '后天', value: 'day_after' },
|
||
{ label: '近7天', value: 'last7' },
|
||
{ label: '近30天', value: 'last30' }
|
||
]
|
||
|
||
const { pager, getLists, resetPage } = usePaging({
|
||
fetchFun: myPatientLists as any,
|
||
params: formData,
|
||
size: 15,
|
||
firstLoading: true
|
||
})
|
||
|
||
const summary = computed(() => ({
|
||
today: Number(pager.extend?.summary?.today || 0),
|
||
tomorrow: Number(pager.extend?.summary?.tomorrow || 0),
|
||
day_after: Number(pager.extend?.summary?.day_after || 0)
|
||
}))
|
||
const summaryDates = computed(() => pager.extend?.dates || {})
|
||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||
const canEditDiagnosis = computed(() => hasPermission(['tcm.diagnosis/edit']))
|
||
const canReadDiagnosis = computed(() => hasPermission(['tcm.diagnosis/readonlyDetail']))
|
||
const canBookAppointment = computed(() => hasPermission(['tcm.diagnosis/guahao']))
|
||
const canAssignPatient = computed(() => hasPermission(['tcm.diagnosis/assign']))
|
||
const canFillIdCard = computed(() => hasPermission(['tcm.diagnosis/edit']))
|
||
const workspaceLoading = computed(() => {
|
||
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
|
||
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
|
||
return pager.loading
|
||
})
|
||
|
||
function handleSearch() {
|
||
resetPage()
|
||
}
|
||
|
||
function handleFilterChange() {
|
||
resetPage()
|
||
}
|
||
|
||
function refreshPage() {
|
||
if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.()
|
||
if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.()
|
||
return getLists()
|
||
}
|
||
|
||
function resetFilters() {
|
||
formData.keyword = ''
|
||
formData.status_filter = ''
|
||
formData.start_date = ''
|
||
formData.end_date = ''
|
||
activeDateType.value = 'all'
|
||
customDateRange.value = []
|
||
resetPage()
|
||
}
|
||
|
||
function selectDateType(type: DateType) {
|
||
activeDateType.value = type
|
||
customDateRange.value = []
|
||
const today = dayjs()
|
||
if (type === 'all') {
|
||
formData.start_date = ''
|
||
formData.end_date = ''
|
||
} else if (type === 'today') {
|
||
formData.start_date = today.format('YYYY-MM-DD')
|
||
formData.end_date = formData.start_date
|
||
} else if (type === 'tomorrow') {
|
||
formData.start_date = today.add(1, 'day').format('YYYY-MM-DD')
|
||
formData.end_date = formData.start_date
|
||
} else if (type === 'day_after') {
|
||
formData.start_date = today.add(2, 'day').format('YYYY-MM-DD')
|
||
formData.end_date = formData.start_date
|
||
} else if (type === 'last7') {
|
||
formData.start_date = today.subtract(6, 'day').format('YYYY-MM-DD')
|
||
formData.end_date = today.format('YYYY-MM-DD')
|
||
} else if (type === 'last30') {
|
||
formData.start_date = today.subtract(29, 'day').format('YYYY-MM-DD')
|
||
formData.end_date = today.format('YYYY-MM-DD')
|
||
}
|
||
resetPage()
|
||
}
|
||
|
||
function handleCustomDateChange(value: string[] | null) {
|
||
if (!value?.length) {
|
||
activeDateType.value = 'all'
|
||
formData.start_date = ''
|
||
formData.end_date = ''
|
||
} else {
|
||
activeDateType.value = 'custom'
|
||
formData.start_date = value[0] || ''
|
||
formData.end_date = value[1] || value[0] || ''
|
||
}
|
||
resetPage()
|
||
}
|
||
|
||
function openDiagnosis(row: any) {
|
||
if (!canEditDiagnosis.value) {
|
||
openReadonlyDiagnosis(row)
|
||
return
|
||
}
|
||
editRef.value?.open?.('edit', Number(row.diagnosis_id || row.id))
|
||
}
|
||
|
||
function openReadonlyDiagnosis(row: any) {
|
||
if (!canReadDiagnosis.value) {
|
||
feedback.msgWarning('当前角色没有诊单查看权限')
|
||
return
|
||
}
|
||
const route = router.getRoutes().find((item) => item.meta?.perms === 'tcm.diagnosis/readonlyDetail')
|
||
if (route?.path) {
|
||
router.push({ path: route.path, query: { id: String(row.diagnosis_id || row.id) } })
|
||
} else {
|
||
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(row.diagnosis_id || row.id) } })
|
||
}
|
||
}
|
||
|
||
function openAppointment(row: any) {
|
||
appointmentRef.value?.open?.({
|
||
...row,
|
||
id: Number(row.diagnosis_id || row.id),
|
||
patient_id: Number(row.diagnosis_id || row.id)
|
||
})
|
||
}
|
||
|
||
function assistantOptionLabel(item: any) {
|
||
const name = String(item?.name || item?.account || `医助${item?.id || ''}`)
|
||
const departments = String(item?.dept_names || '').trim()
|
||
return departments ? `${name} · ${departments}` : name
|
||
}
|
||
|
||
async function loadAssistantOptions() {
|
||
assistantOptionsLoading.value = true
|
||
try {
|
||
const result = await myPatientAssistants()
|
||
assistantOptions.value = Array.isArray(result) ? result : []
|
||
} catch (error: any) {
|
||
assistantOptions.value = []
|
||
feedback.msgError(error?.msg || '医助列表加载失败')
|
||
} finally {
|
||
assistantOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openAssignDialog(row: any) {
|
||
currentActionPatient.value = row
|
||
assignForm.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||
assignForm.is_inherit = false
|
||
assignDialogVisible.value = true
|
||
if (assistantOptions.value.length === 0) {
|
||
await loadAssistantOptions()
|
||
}
|
||
}
|
||
|
||
async function submitAssign() {
|
||
const diagnosisId = Number(currentActionPatient.value?.diagnosis_id || currentActionPatient.value?.id)
|
||
if (diagnosisId <= 0) {
|
||
feedback.msgWarning('患者诊单信息不完整')
|
||
return
|
||
}
|
||
if (!assignForm.assistant_id) {
|
||
feedback.msgWarning('请选择医助')
|
||
return
|
||
}
|
||
|
||
assignLoading.value = true
|
||
try {
|
||
await myPatientAssign({
|
||
id: diagnosisId,
|
||
assistant_id: Number(assignForm.assistant_id),
|
||
is_inherit: assignForm.is_inherit ? 1 : 0
|
||
})
|
||
feedback.msgSuccess('指派成功')
|
||
assignDialogVisible.value = false
|
||
await getLists()
|
||
} catch (error: any) {
|
||
feedback.msgError(error?.msg || '指派失败')
|
||
} finally {
|
||
assignLoading.value = false
|
||
}
|
||
}
|
||
|
||
const validateIdCard = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
||
const idCard = String(value || '').trim()
|
||
if (!idCard) {
|
||
callback(new Error('请输入身份证号'))
|
||
return
|
||
}
|
||
const valid18 = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)
|
||
const valid15 = /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/.test(idCard)
|
||
callback(valid18 || valid15 ? undefined : new Error('请输入15或18位有效身份证号'))
|
||
}
|
||
|
||
const fillIdCardRules = {
|
||
id_card: [{ required: true, validator: validateIdCard, trigger: 'blur' }]
|
||
}
|
||
|
||
function openFillIdCardDialog(row: any) {
|
||
fillIdCardForm.id = Number(row.diagnosis_id || row.id)
|
||
fillIdCardForm.patient_name = String(row.patient_name || '')
|
||
fillIdCardForm.id_card = ''
|
||
fillIdCardDialogVisible.value = true
|
||
}
|
||
|
||
function resetFillIdCardForm() {
|
||
fillIdCardForm.id = 0
|
||
fillIdCardForm.patient_name = ''
|
||
fillIdCardForm.id_card = ''
|
||
fillIdCardFormRef.value?.clearValidate?.()
|
||
}
|
||
|
||
async function submitFillIdCard() {
|
||
if (fillIdCardLoading.value) return
|
||
try {
|
||
await fillIdCardFormRef.value?.validate?.()
|
||
} catch {
|
||
return
|
||
}
|
||
fillIdCardLoading.value = true
|
||
try {
|
||
await myPatientFillIdCard({
|
||
id: fillIdCardForm.id,
|
||
id_card: fillIdCardForm.id_card.trim()
|
||
})
|
||
feedback.msgSuccess('补全成功,年龄已自动更新')
|
||
fillIdCardDialogVisible.value = false
|
||
await getLists()
|
||
} catch (error: any) {
|
||
feedback.msgError(error?.msg || '补全身份证失败')
|
||
} finally {
|
||
fillIdCardLoading.value = false
|
||
}
|
||
}
|
||
|
||
function canShowDiagnosisQRCode(row: any) {
|
||
return canBookAppointment.value
|
||
&& Number(row.appointment_id) > 0
|
||
&& Number(row.appointment_status) === 1
|
||
&& Number(row.appointment_doctor_id) > 0
|
||
}
|
||
|
||
async function openDiagnosisQRCode(row: any) {
|
||
if (!canShowDiagnosisQRCode(row)) {
|
||
feedback.msgWarning('仅已挂号患者可以生成诊单二维码')
|
||
return
|
||
}
|
||
const diagnosisId = Number(row.diagnosis_id || row.id)
|
||
const patientId = Number(row.source_patient_id || row.diagnosis_id || row.id)
|
||
if (diagnosisId <= 0 || patientId <= 0) {
|
||
feedback.msgWarning('患者诊单信息不完整')
|
||
return
|
||
}
|
||
|
||
currentQRCodePatient.value = row
|
||
qrcodeDialogVisible.value = true
|
||
qrcodeLoading.value = true
|
||
qrcodeUrl.value = ''
|
||
try {
|
||
const config = await getWeappConfig()
|
||
if (!config?.app_id) {
|
||
throw new Error('小程序未配置,请先配置小程序信息')
|
||
}
|
||
const result = await generateMiniProgramQrcode({
|
||
diagnosis_id: diagnosisId,
|
||
doctor_id: Number(row.appointment_doctor_id),
|
||
patient_id: patientId,
|
||
share_user_id: userStore.userInfo?.id || ''
|
||
})
|
||
if (!result?.qrcode_url) {
|
||
throw new Error('二维码生成失败')
|
||
}
|
||
qrcodeUrl.value = result.qrcode_url
|
||
} catch (error: any) {
|
||
feedback.msgError(error?.msg || error?.message || '生成二维码失败,请重试')
|
||
} finally {
|
||
qrcodeLoading.value = false
|
||
}
|
||
}
|
||
|
||
function regenerateDiagnosisQRCode() {
|
||
if (currentQRCodePatient.value) {
|
||
openDiagnosisQRCode(currentQRCodePatient.value)
|
||
}
|
||
}
|
||
|
||
function canCancelAppointment(row: any) {
|
||
return Number(row.appointment_id) > 0 && [1, 4].includes(Number(row.appointment_status))
|
||
}
|
||
|
||
async function cancelAppointmentForRow(row: any) {
|
||
if (!canCancelAppointment(row)) return
|
||
try {
|
||
await feedback.confirm(`确定取消“${row.patient_name || '该患者'}”的挂号吗?`)
|
||
await myPatientCancelAppointment({ id: Number(row.appointment_id) })
|
||
feedback.msgSuccess('取消挂号成功')
|
||
await refreshPage()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel') {
|
||
feedback.msgError(error?.msg || '取消挂号失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
function patientInitial(name: string) {
|
||
const text = String(name || '患').trim()
|
||
return text.slice(0, 1)
|
||
}
|
||
|
||
function appointmentTagType(status: number): 'primary' | 'success' | 'warning' | 'info' {
|
||
return ({ 1: 'primary', 3: 'success', 4: 'warning' } as const)[Number(status) as 1 | 3 | 4] || 'info'
|
||
}
|
||
|
||
function tableRowClassName({ row }: { row: any }) {
|
||
if (!row.confirmed) return 'patient-row-unconfirmed'
|
||
if (Number(row.appointment_status) === 4) return 'patient-row-missed'
|
||
return ''
|
||
}
|
||
|
||
onMounted(() => {
|
||
getLists()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.my-patients-page {
|
||
min-height: calc(100vh - 90px);
|
||
padding: 18px;
|
||
background: #f5f7fa;
|
||
color: #172033;
|
||
}
|
||
|
||
.qrcode-body {
|
||
display: flex;
|
||
min-height: 320px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
color: #7b8798;
|
||
}
|
||
|
||
.qrcode-image {
|
||
width: 256px;
|
||
height: 256px;
|
||
border: 1px solid #e1e7ed;
|
||
border-radius: 10px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.qrcode-meta {
|
||
display: grid;
|
||
gap: 6px;
|
||
color: #697587;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.qrcode-error {
|
||
color: var(--el-color-danger);
|
||
}
|
||
|
||
.page-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
margin-bottom: 16px;
|
||
|
||
.eyebrow {
|
||
margin-bottom: 3px;
|
||
color: #0f9185;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 24px;
|
||
line-height: 1.3;
|
||
font-weight: 700;
|
||
}
|
||
|
||
p {
|
||
margin: 5px 0 0;
|
||
color: #8490a3;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.heading-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
|
||
:deep(.el-tag) {
|
||
color: #0f766e;
|
||
border-color: #a7ded8;
|
||
background: #effbf9;
|
||
}
|
||
}
|
||
|
||
.workspace-card {
|
||
border: 1px solid #e3e8ef;
|
||
border-radius: 12px;
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 0 16px 16px;
|
||
}
|
||
}
|
||
|
||
.workspace-tabs {
|
||
:deep(.el-tabs__header) {
|
||
margin: 0 -16px 16px;
|
||
padding: 0 16px;
|
||
background: #fbfcfd;
|
||
border-bottom: 1px solid #e9edf2;
|
||
}
|
||
|
||
:deep(.el-tabs__nav-wrap::after) {
|
||
display: none;
|
||
}
|
||
|
||
:deep(.el-tabs__item) {
|
||
height: 50px;
|
||
padding: 0 24px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-tabs__active-bar) {
|
||
height: 3px;
|
||
background: #0f9185;
|
||
}
|
||
}
|
||
|
||
.filter-panel {
|
||
padding: 14px;
|
||
border: 1px solid #e3e8ef;
|
||
border-radius: 10px;
|
||
background: #fbfcfd;
|
||
}
|
||
|
||
.filter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.filter-row-main {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.keyword-input {
|
||
width: min(390px, 100%);
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.filter-label {
|
||
flex: 0 0 auto;
|
||
color: #5d687a;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.date-filter-row {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #e8edf2;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.quick-dates {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 7px;
|
||
|
||
:deep(.el-button + .el-button) {
|
||
margin-left: 0;
|
||
}
|
||
}
|
||
|
||
.date-range {
|
||
width: 280px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin: 14px 0;
|
||
}
|
||
|
||
.summary-card {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 96px;
|
||
padding: 16px;
|
||
text-align: left;
|
||
border: 1px solid #dfe6ee;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||
|
||
&:hover {
|
||
border-color: #82cfc7;
|
||
box-shadow: 0 8px 20px rgba(28, 74, 70, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
}
|
||
|
||
.summary-icon {
|
||
display: grid;
|
||
width: 42px;
|
||
height: 42px;
|
||
margin-right: 13px;
|
||
place-items: center;
|
||
border-radius: 10px;
|
||
color: #0f9185;
|
||
background: #e8f7f5;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.summary-copy {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 5px;
|
||
|
||
small {
|
||
position: absolute;
|
||
top: 16px;
|
||
color: #718096;
|
||
font-size: 13px;
|
||
}
|
||
|
||
strong {
|
||
margin-top: 20px;
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
}
|
||
|
||
em {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
font-style: normal;
|
||
}
|
||
}
|
||
|
||
.summary-date {
|
||
margin-left: auto;
|
||
color: #98a2b3;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.summary-tomorrow .summary-icon {
|
||
color: #3978d6;
|
||
background: #edf4ff;
|
||
}
|
||
|
||
.summary-after .summary-icon {
|
||
color: #c27728;
|
||
background: #fff4e8;
|
||
}
|
||
|
||
.table-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin: 4px 0 10px;
|
||
|
||
> div {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
span {
|
||
color: #8a95a6;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.table-scope {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
|
||
.patient-table {
|
||
width: 100%;
|
||
border: 1px solid #e7ebf0;
|
||
border-radius: 9px;
|
||
overflow: hidden;
|
||
|
||
:deep(th.el-table__cell) {
|
||
height: 44px;
|
||
color: #5f6b7d;
|
||
background: #f7f9fb;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(td.el-table__cell) {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
:deep(.patient-row-unconfirmed > td.el-table__cell) {
|
||
background: #fffaf0;
|
||
}
|
||
|
||
:deep(.patient-row-missed > td.el-table__cell) {
|
||
background: #fffdf8;
|
||
}
|
||
}
|
||
|
||
.patient-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
|
||
strong {
|
||
display: block;
|
||
color: #202939;
|
||
font-size: 14px;
|
||
}
|
||
|
||
p {
|
||
margin: 4px 0 0;
|
||
color: #8b96a8;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.patient-avatar {
|
||
display: grid;
|
||
flex: 0 0 36px;
|
||
width: 36px;
|
||
height: 36px;
|
||
place-items: center;
|
||
border-radius: 10px;
|
||
color: #0f766e;
|
||
background: #e5f5f2;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.doctor-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
|
||
.appointment-time {
|
||
color: #344054;
|
||
|
||
&.empty {
|
||
color: #98a2b3;
|
||
}
|
||
}
|
||
|
||
.revisit-count {
|
||
color: #475467;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.row-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
white-space: nowrap;
|
||
|
||
:deep(.el-button + .el-button) {
|
||
margin-left: 10px;
|
||
}
|
||
}
|
||
|
||
.dialog-full-width {
|
||
width: 100%;
|
||
}
|
||
|
||
.dialog-patient-name {
|
||
color: #1f2937;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.inherit-field {
|
||
display: grid;
|
||
gap: 2px;
|
||
|
||
small {
|
||
color: #98a2b3;
|
||
line-height: 1.5;
|
||
}
|
||
}
|
||
|
||
.pagination-wrap {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding-top: 16px;
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.summary-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.date-range {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.my-patients-page {
|
||
padding: 12px;
|
||
}
|
||
|
||
.page-heading {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.filter-row,
|
||
.filter-group {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.status-group :deep(.el-radio-group) {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
</style>
|