Files
zyt/admin/src/views/tcm/appointment/list.vue
T
2026-06-01 18:30:29 +08:00

1465 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="appointment-list">
<el-card class="list-card !border-none" shadow="never">
<!-- 单行工具栏Tab() + 搜索() -->
<div class="list-toolbar">
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="list-tabs">
<el-tab-pane name="1">
<template #label>
<span class="tab-label tab-priority"><el-icon><Clock /></el-icon>待接诊<el-badge v-if="statusCount[1]" :value="statusCount[1]" class="tab-badge tab-badge-success" /></span>
</template>
</el-tab-pane>
<el-tab-pane name="all">
<template #label>
<span class="tab-label"><el-icon><List /></el-icon>全部</span>
</template>
</el-tab-pane>
<el-tab-pane name="4">
<template #label>
<span class="tab-label"><el-icon><Warning /></el-icon>已过号<el-badge v-if="statusCount[4]" :value="statusCount[4]" class="tab-badge tab-badge-danger" /></span>
</template>
</el-tab-pane>
<el-tab-pane name="2">
<template #label>
<span class="tab-label"><el-icon><CircleClose /></el-icon>已取消<el-badge v-if="statusCount[2]" :value="statusCount[2]" class="tab-badge tab-badge-info" /></span>
</template>
</el-tab-pane>
<el-tab-pane name="3">
<template #label>
<span class="tab-label"><el-icon><CircleCheck /></el-icon>已完成<el-badge v-if="statusCount[3]" :value="statusCount[3]" class="tab-badge tab-badge-primary" /></span>
</template>
</el-tab-pane>
</el-tabs>
<div class="toolbar-actions">
<el-input
v-model="formData.patient_name"
placeholder="患者姓名"
clearable
size="small"
class="action-input"
@keyup.enter="handleSearch"
/>
<el-button type="primary" size="small" @click="handleSearch">查询</el-button>
<el-dropdown trigger="click" @command="handleMoreCommand">
<el-button size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="isAdmin" class="dropdown-item-with-input" @click.stop>
<span class="dropdown-label">医生</span>
<el-input v-model="formData.doctor_name" placeholder="医生" size="small" class="dropdown-input" @click.stop />
</el-dropdown-item>
<el-dropdown-item command="customDate" divided>
<el-icon><Calendar /></el-icon>
自定义日期
</el-dropdown-item>
<el-dropdown-item command="reset">
<el-icon><RefreshRight /></el-icon>
重置
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 日期 + 确认诊单 快捷筛选 -->
<div class="filter-bar">
<div class="filter-group">
<span class="filter-label">日期</span>
<el-radio-group v-model="formData.date_preset" size="small" @change="handleDatePresetChange" class="filter-radio">
<el-radio-button value="yesterday">昨天挂号</el-radio-button>
<el-radio-button value="day_before">前天挂号</el-radio-button>
<el-radio-button value="today">当天挂号</el-radio-button>
<el-radio-button value="tomorrow">明天挂号</el-radio-button>
<el-radio-button value="day_after">后天挂号</el-radio-button>
<el-radio-button value="">不限</el-radio-button>
</el-radio-group>
</div>
<div class="filter-group">
<span class="filter-label">确认诊单</span>
<el-radio-group v-model="formData.diagnosis_confirmed" size="small" @change="handleDiagnosisConfirmedChange" class="filter-radio">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="1">已确认</el-radio-button>
<el-radio-button value="0">未确认</el-radio-button>
</el-radio-group>
</div>
</div>
<el-dialog
v-model="dateCustomVisible"
title="自定义日期"
width="400px"
append-to-body
:close-on-click-modal="true"
>
<daterange-picker
v-model:startTime="formData.start_date"
v-model:endTime="formData.end_date"
/>
<template #footer>
<el-button @click="dateCustomVisible = false">取消</el-button>
<el-button type="primary" @click="applyCustomDate">确定</el-button>
</template>
</el-dialog>
<div class="table-wrap">
<el-table
v-loading="pager.loading"
:data="pager.lists"
size="default"
class="appointment-table"
>
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="患者信息" min-width="140">
<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-phone">{{ maskPhone(row.patient_phone) }}</div>
<div class="patient-extra">
{{ [row.gender === 1 ? '男' : row.gender === 0 ? '女' : '', row.age != null ? row.age + '岁' : '', row.height != null ? row.height + 'cm' : '', row.weight != null ? row.weight + 'kg' : ''].filter(Boolean).join(' ') || '-' }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="90" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="120">
<template #default="{ row }">
<div class="datetime-cell">
<div class="date-text">{{ row.appointment_date }}</div>
<div class="time-text">{{ row.appointment_time }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="助理" prop="assistant_name" width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ row.assistant_name || '—' }}
</template>
</el-table-column>
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
<el-table-column label="确认诊单" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.diagnosis_confirmed" type="success" size="small" effect="plain">已确认</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">未确认</el-tag>
</template>
</el-table-column>
<el-table-column label="开方" width="120" align="center">
<template #default="{ row }">
<div class="prescription-tags">
<el-tag v-if="row.has_prescription" type="success" size="small" effect="plain">已开方</el-tag>
<el-tag v-else type="info" size="small" effect="plain">未开方</el-tag>
<el-tag
v-if="row.has_prescription && row.prescription_is_system_auto === 1"
type="warning"
size="small"
effect="plain"
>
系统代开
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'"
size="small"
effect="light"
round
>
{{ row.status_desc }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="380" fixed="right" align="left">
<template #default="{ row }">
<div class="action-btns">
<el-button
v-perms="['tcm.diagnosis/edit']"
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑患者
</el-button>
<template v-if="row.status !== 3">
<el-button
v-perms="['tcm.diagnosis/videoQr']"
type="warning"
link
size="small"
@click="handleMiniProgramQRCode(row)"
class="action-qrcode"
>
<el-icon><Picture /></el-icon>
视频二维码
</el-button>
<el-button
v-perms="['doctor.appointment/prescription']"
type="primary"
link
size="small"
@click="handleChat(row)"
>
通话
</el-button>
<el-button
v-perms="['doctor.appointment/complete']"
type="success"
link
size="small"
@click="handleComplete(row)"
>
完成
</el-button>
</template>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
type="primary"
link
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleAction(cmd, row)" placement="bottom-end">
<el-button type="info" link size="small">
更多
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-perms="['tcm.diagnosis/kaifang']"
command="viewCase"
>
<el-icon><Document /></el-icon>
病历
</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 1"
v-perms="['doctor.appointment/cancel']"
command="cancel"
divided
>
<el-icon><CircleClose /></el-icon>
<span class="text-danger">取消挂号</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end py-4 px-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<!-- 详情弹窗 -->
<el-dialog
v-model="detailVisible"
title="挂号详情"
width="720px"
class="detail-dialog"
destroy-on-close
>
<div v-if="detailData" class="detail-content">
<!-- 预约信息 -->
<div class="detail-section">
<div class="detail-section-title">
<el-icon><Calendar /></el-icon>
预约信息
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">挂号ID</span>
<span class="detail-value">{{ detailData.id }}</span>
</div>
<div class="detail-item">
<span class="detail-label">状态</span>
<el-tag
:type="detailData.status === 1 ? 'success' : detailData.status === 2 ? 'info' : detailData.status === 3 ? 'primary' : 'danger'"
size="small"
effect="light"
>
{{ detailData.status_desc }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">医生</span>
<span class="detail-value">{{ detailData.doctor_name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">预约时间</span>
<span class="detail-value">{{ detailData.appointment_date }} {{ detailData.appointment_time }}</span>
</div>
<div class="detail-item">
<span class="detail-label">时段</span>
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">预约类型</span>
<span class="detail-value">{{ detailData.appointment_type_desc }}</span>
</div>
</div>
</div>
<!-- 患者基本信息含编辑患者中的字段 -->
<div class="detail-section">
<div class="detail-section-title">
<el-icon><User /></el-icon>
患者信息
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">姓名</span>
<span class="detail-value">{{ detailData.patient_name || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">手机号</span>
<span class="detail-value">{{ detailData.phone || detailData.patient_phone || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">身份证号</span>
<span class="detail-value">{{ detailData.id_card || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">性别</span>
<span class="detail-value">{{ detailData.gender === 1 ? '男' : detailData.gender === 0 ? '女' : '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">年龄</span>
<span class="detail-value">{{ detailData.age != null ? detailData.age + '岁' : '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">诊断日期</span>
<span class="detail-value">{{ detailData.diagnosis_date || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">婚姻状态</span>
<span class="detail-value">{{ detailData.marital_status === 0 ? '未婚' : detailData.marital_status === 1 ? '已婚' : detailData.marital_status === 2 ? '离异' : '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">身高(cm)</span>
<span class="detail-value">{{ detailData.height ?? '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">体重(kg)</span>
<span class="detail-value">{{ detailData.weight ?? '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">地区</span>
<span class="detail-value">{{ detailData.region || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">收缩压(mmHg)</span>
<span class="detail-value">{{ detailData.systolic_pressure ?? '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">舒张压(mmHg)</span>
<span class="detail-value">{{ detailData.diastolic_pressure ?? '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">空腹血糖(mmol/L)</span>
<span class="detail-value">{{ detailData.fasting_blood_sugar ?? '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">诊断类型</span>
<span class="detail-value">{{ getDictLabel(diagnosisTypeOptions, detailData.diagnosis_type) || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">证型</span>
<span class="detail-value">{{ getDictLabel(syndromeTypeOptions, detailData.syndrome_type) || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">糖尿病期数</span>
<span class="detail-value">{{ getDictLabel(diabetesTypeOptions, detailData.diabetes_type) || '-' }}</span>
</div>
</div>
</div>
<!-- 主诉/现病史 -->
<div v-if="detailData.symptoms || detailData.local_hospital_name || (detailData.past_history && detailData.past_history.length)" class="detail-section">
<div class="detail-section-title">
<el-icon><Document /></el-icon>
病史信息
</div>
<div class="detail-grid">
<div v-if="isDiabetesDiscoveryFilled(detailData.diabetes_discovery_year)" class="detail-item">
<span class="detail-label">发现糖尿病病史</span>
<span class="detail-value">{{ formatDiabetesDiscoveryDisplay(detailData.diabetes_discovery_year) }}</span>
</div>
<div v-if="detailData.local_hospital_name" class="detail-item">
<span class="detail-label">当地就诊医院</span>
<span class="detail-value">{{ detailData.local_hospital_name }}</span>
</div>
<div v-if="detailData.symptoms" class="detail-item detail-item-full">
<span class="detail-label">症状补充</span>
<span class="detail-value">{{ detailData.symptoms }}</span>
</div>
<div v-if="detailData.past_history && detailData.past_history.length" class="detail-item detail-item-full">
<span class="detail-label">既往史</span>
<span class="detail-value">{{ getPastHistoryLabels(detailData.past_history) }}</span>
</div>
</div>
</div>
<!-- 舌苔/脉象/治则 -->
<div v-if="detailData.tongue_coating || detailData.pulse || detailData.treatment_principle || detailData.prescription" class="detail-section">
<div class="detail-section-title">
<el-icon><Document /></el-icon>
诊断信息
</div>
<div class="detail-grid">
<div v-if="detailData.tongue_coating" class="detail-item">
<span class="detail-label">舌苔</span>
<span class="detail-value">{{ detailData.tongue_coating }}</span>
</div>
<div v-if="detailData.pulse" class="detail-item">
<span class="detail-label">脉象</span>
<span class="detail-value">{{ detailData.pulse }}</span>
</div>
<div v-if="detailData.treatment_principle" class="detail-item detail-item-full">
<span class="detail-label">治则</span>
<span class="detail-value">{{ detailData.treatment_principle }}</span>
</div>
<div v-if="detailData.prescription" class="detail-item detail-item-full">
<span class="detail-label">处方</span>
<span class="detail-value">{{ detailData.prescription }}</span>
</div>
</div>
</div>
<!-- 备注 -->
<div class="detail-section">
<div class="detail-section-title">备注</div>
<p class="detail-remark">{{ detailData.remark || '无' }}</p>
</div>
</div>
</el-dialog>
<!-- 编辑患者弹窗 -->
<edit-popup ref="editRef" @success="() => loadData({ silent: true })" />
<!-- 中医处方单 -->
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
<!-- 聊天对话框 -->
<chat-dialog ref="chatDialogRef" />
<!-- 小程序二维码弹窗 -->
<el-dialog
v-model="qrcodeDialogVisible"
title="小程序二维码"
width="380px"
:close-on-click-modal="false"
class="qrcode-dialog"
>
<div class="qrcode-content">
<div v-if="qrcodeLoading" class="qrcode-loading">
<el-icon class="is-loading" :size="48">
<Loading />
</el-icon>
<div class="qrcode-loading-text">生成中...</div>
</div>
<div v-else-if="qrcodeUrl" class="qrcode-success">
<div class="qrcode-image-wrap">
<img :src="qrcodeUrl" alt="小程序二维码" class="qrcode-image" />
</div>
<div class="qrcode-info">
<div class="qrcode-patient">患者{{ currentQRCodeRow?.patient_name }}</div>
<div class="qrcode-tip">请使用微信扫描二维码</div>
<div class="qrcode-path">pages/login/login</div>
</div>
</div>
<div v-else class="qrcode-error">
<el-icon :size="40"><Warning /></el-icon>
<div>生成失败请重试</div>
</div>
</div>
<template #footer>
<el-button @click="qrcodeDialogVisible = false">关闭</el-button>
<el-button v-if="!qrcodeLoading" type="primary" @click="handleRegenerateQRCode">
重新生成
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="completeDialogVisible"
title="完成问诊"
width="420px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<div class="complete-dialog">
<p class="complete-dialog-tip">是否添加医生备注</p>
<el-input
v-if="canAddCompleteNote"
v-model="completeNoteContent"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="填写完成备注,将追加到医生备注时间轴"
/>
</div>
<template #footer>
<el-button @click="completeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeSubmitting" @click="submitComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import {
formatDiabetesDiscoveryDisplay,
isDiabetesDiscoveryFilled
} from '@/utils/diabetes-discovery-display'
// 重组件异步加载,避免与列表同 chunk 阻塞路由进入与首帧渲染
const EditPopup = defineAsyncComponent(() => import('../diagnosis/edit.vue'))
const TcmPrescription = defineAsyncComponent(() => import('@/components/tcm-prescription/index.vue'))
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
import useUserStore from '@/stores/modules/user'
import { getWeappConfig } from '@/api/channel/weapp'
import {
Loading,
User,
List,
Clock,
Warning,
CircleClose,
CircleCheck,
ArrowDown,
Edit,
ChatDotRound,
Picture,
Document,
Calendar,
RefreshRight
} from '@element-plus/icons-vue'
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
// 手机号脱敏处理
const maskPhone = (phone: string) => {
if (!phone || phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 判断是否为管理员(非医生、非医助)
const isAdmin = computed(() => {
const roleId = userInfo.value.role_id
// 如果role_id是数组,检查是否包含医生(1)或医助(2)
if (Array.isArray(roleId)) {
return !roleId.includes(1) && !roleId.includes(2)
}
// 如果role_id是单个值,检查是否不是医生或医助
return roleId !== 1 && roleId !== 2
})
const formData = reactive({
patient_name: '',
doctor_name: '',
status: '' as string | number,
start_date: '',
end_date: '',
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
include_status_counts: 0 as 0 | 1
})
const activeTab = ref('1')
const dateCustomVisible = ref(false)
const statusCount = ref<Record<number, number>>({
1: 0,
2: 0,
3: 0,
4: 0
})
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: appointmentLists,
params: formData,
firstLoading: true
})
// 手动修改日期范围时清除快捷日期
watch(
() => [formData.start_date, formData.end_date],
([start, end]) => {
if (!formData.date_preset) return
const today = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const todayStr = fmt(today)
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = fmt(yesterday)
const dayBefore = new Date(today)
dayBefore.setDate(dayBefore.getDate() - 2)
const dayBeforeStr = fmt(dayBefore)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = fmt(tomorrow)
const dayAfter = new Date(today)
dayAfter.setDate(dayAfter.getDate() + 2)
const dayAfterStr = fmt(dayAfter)
const preset = formData.date_preset
const expected =
preset === 'yesterday'
? yesterdayStr
: preset === 'day_before'
? dayBeforeStr
: preset === 'today'
? todayStr
: preset === 'tomorrow'
? tomorrowStr
: dayAfterStr
const startStr = (start || '').split(' ')[0]
if (startStr !== expected) {
formData.date_preset = ''
}
}
)
// 切换选项卡
const handleTabChange = (tabName: string | number) => {
if (tabName === 'all') {
formData.status = ''
} else {
formData.status = Number(tabName)
}
resetPage()
}
/** 列表静默轮询间隔(毫秒) */
const LIST_POLL_MS = 20_000
let listPollTimer: ReturnType<typeof setInterval> | null = null
// 列表 + Tab 角标:一次请求(后端 extend.status_count),翻页等仅 getLists 不重复统计
const loadData = async (opts?: { silent?: boolean }) => {
const silent = opts?.silent === true
formData.include_status_counts = 1
try {
await getLists({ silent })
const ext = pager.extend as { status_count?: Record<number | string, number> }
const sc = ext?.status_count
if (sc) {
for (const k of [1, 2, 3, 4] as const) {
const n = sc[k] ?? sc[String(k)]
if (typeof n === 'number') {
statusCount.value[k] = n
}
}
}
} catch (e) {
/* 定时静默刷新失败不向外抛,避免未处理的 rejection;手动刷新仍交给上层/拦截器 */
if (!silent) {
throw e
}
} finally {
formData.include_status_counts = 0
}
}
// 更多下拉命令
const handleMoreCommand = (cmd: string) => {
if (cmd === 'customDate') dateCustomVisible.value = true
else if (cmd === 'reset') handleReset()
}
// 自定义日期确定
const applyCustomDate = () => {
formData.date_preset = ''
dateCustomVisible.value = false
pager.page = 1
loadData()
}
// 确认诊单筛选变更
const handleDiagnosisConfirmedChange = () => {
pager.page = 1
loadData()
}
// 快捷日期变更
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
const v = String(val || '')
if (!v) {
formData.date_preset = ''
formData.start_date = ''
formData.end_date = ''
loadData()
return
}
const today = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
let target: Date
if (v === 'yesterday') {
target = new Date(today)
target.setDate(target.getDate() - 1)
} else if (v === 'day_before') {
target = new Date(today)
target.setDate(target.getDate() - 2)
} else if (v === 'today') {
target = today
} else if (v === 'tomorrow') {
target = new Date(today)
target.setDate(target.getDate() + 1)
} else {
target = new Date(today)
target.setDate(target.getDate() + 2)
}
const dateStr = `${target.getFullYear()}-${pad(target.getMonth() + 1)}-${pad(target.getDate())}`
formData.date_preset = v as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after'
formData.start_date = dateStr
formData.end_date = dateStr
pager.page = 1
loadData()
}
// 搜索
const handleSearch = () => {
pager.page = 1
loadData()
}
// 重置(恢复默认:待接诊 + 今天 + 全部确认状态)
const handleReset = () => {
activeTab.value = '1'
formData.patient_name = ''
formData.doctor_name = ''
formData.date_preset = 'today'
formData.diagnosis_confirmed = ''
const t = new Date()
const p = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
formData.end_date = formData.start_date
formData.status = 1
pager.page = 1
loadData()
}
const detailVisible = ref(false)
const detailData = ref<any>(null)
// 字典选项(用于详情展示)
const diagnosisTypeOptions = ref<any[]>([])
const syndromeTypeOptions = ref<any[]>([])
const diabetesTypeOptions = ref<any[]>([])
const pastHistoryOptions = ref<any[]>([])
const getDictLabel = (options: any[], value: string) => {
if (!value) return '-'
const item = options.find((opt: any) => opt.value === value)
return item ? item.name : value
}
const getPastHistoryLabels = (values: string[] | string) => {
const arr = Array.isArray(values) ? values : (typeof values === 'string' ? values.split(/[,,、]/) : [])
return arr.map((v: string) => getDictLabel(pastHistoryOptions.value, v.trim())).filter(Boolean).join('、') || '-'
}
const loadDetailDictOptions = async () => {
const [dt, st, db, ph] = await Promise.all([
getDictData({ type: 'diagnosis_type' }),
getDictData({ type: 'syndrome_type' }),
getDictData({ type: 'diabetes_type' }),
getDictData({ type: 'past_history' })
])
diagnosisTypeOptions.value = dt?.diagnosis_type || []
syndromeTypeOptions.value = st?.syndrome_type || []
diabetesTypeOptions.value = db?.diabetes_type || []
pastHistoryOptions.value = ph?.past_history || []
}
const detailDictReady = ref(false)
const ensureDetailDictOptions = async () => {
if (detailDictReady.value) return
try {
await loadDetailDictOptions()
detailDictReady.value = true
} catch (e) {
console.error('加载字典失败:', e)
}
}
const editRef = ref()
const prescriptionRef = ref()
const chatDialogRef = ref()
const COMPLETE_NOTE_PERMISSION = ['doctor.appointment/addDoctorNote']
const completeDialogVisible = ref(false)
const completeSubmitting = ref(false)
const completeNoteContent = ref('')
const completeRow = ref<any>(null)
const canAddCompleteNote = computed(() => hasPermission(COMPLETE_NOTE_PERMISSION))
// 小程序二维码相关
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
const currentQRCodeRow = ref<any>(null)
// 下拉操作
const handleAction = (command: string, row: any) => {
switch (command) {
case 'edit':
handleEditPatient(row)
break
case 'viewCase':
handleViewCase(row)
break
case 'complete':
handleComplete(row)
break
case 'chat':
handleChat(row)
break
case 'qrcode':
handleMiniProgramQRCode(row)
break
case 'prescription':
handlePrescription(row)
break
case 'cancel':
handleCancel(row)
break
}
}
// 查看详情(含患者诊单完整信息)
const handleDetail = async (row: any) => {
try {
const detailBundle = await Promise.all([ensureDetailDictOptions(), appointmentDetail({ id: row.id })])
const res = detailBundle[1]
detailData.value = res
// 有 patient_id(即诊单ID)时,拉取患者完整信息
if (res.patient_id) {
try {
const diagnosis = await tcmDiagnosisDetail({ id: res.patient_id })
detailData.value = { ...res, ...diagnosis }
} catch (e) {
console.warn('获取患者诊单详情失败:', e)
}
}
detailVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 取消挂号
const handleCancel = async (row: any) => {
try {
await feedback.confirm('确定要取消该挂号吗?')
await cancelAppointment({ id: row.id })
feedback.msgSuccess('取消成功')
loadData()
} catch (error) {
// 用户取消操作
}
}
// 完成挂号
const handleComplete = async (row: any) => {
completeRow.value = row
completeNoteContent.value = ''
if (canAddCompleteNote.value) {
completeDialogVisible.value = true
return
}
try {
await feedback.confirm('是否添加医生备注?')
await completeAppointment({ id: row.id })
feedback.msgSuccess('操作成功')
loadData()
} catch (error) {
// 用户取消操作
}
}
const submitComplete = async () => {
const row = completeRow.value
if (!row?.id || completeSubmitting.value) return
completeSubmitting.value = true
try {
const note = completeNoteContent.value.trim()
await completeAppointment({ id: row.id })
let noteFailureMsg = ''
if (canAddCompleteNote.value && note) {
const diagnosisId = row.patient_id || row.diagnosis_id
if (diagnosisId) {
try {
await addDoctorNote({ diagnosis_id: Number(diagnosisId), content: note })
} catch (e) {
noteFailureMsg = '问诊已完成,但备注未保存'
}
} else {
noteFailureMsg = '问诊已完成,但该预约没有关联诊单,备注未保存'
}
}
noteFailureMsg ? feedback.msgWarning(noteFailureMsg) : feedback.msgSuccess('操作成功')
completeDialogVisible.value = false
completeNoteContent.value = ''
completeRow.value = null
loadData()
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '操作失败')
} finally {
completeSubmitting.value = false
}
}
// 编辑患者资料
const handleEditPatient = (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('该预约没有关联诊单信息')
return
}
editRef.value?.open('edit', row.patient_id)
}
// 编辑患者(按钮直接调用)
const handleEdit = (row: any) => {
handleEditPatient(row)
}
// 聊天
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
// 检查是否有诊单ID
if (!row.diagnosis_id && !row.id) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
// 获取聊天签名信息
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
console.log('获取聊天签名成功:', res)
// 直接打开聊天对话框,传入必要的参数
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res // 传入签名数据
})
} catch (error: any) {
console.error('获取聊天签名失败:', error)
feedback.msgError(error.message || '获取聊天签名失败')
}
}
// 生成小程序二维码(跳转小程序路径:pages/login/login
const handleMiniProgramQRCode = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
currentQRCodeRow.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
try {
// 获取小程序配置
const config = await getWeappConfig()
if (!config?.app_id) {
feedback.msgError('小程序未配置,请先配置小程序信息')
qrcodeDialogVisible.value = false
return
}
const currentUser = userStore.userInfo
const result = await generateMiniProgramQrcode({
diagnosis_id: row.doctor_id ||'',
patient_id: row.patient_id,
share_user_id: currentUser?.id || '',
mini_program_path: 'pages/login/login'
})
if (result?.qrcode_url) {
qrcodeUrl.value = result.qrcode_url
} else {
feedback.msgError('二维码生成失败')
}
} catch (error: any) {
console.error('生成小程序二维码失败:', error)
feedback.msgError(error?.msg || '生成二维码失败,请重试')
} finally {
qrcodeLoading.value = false
}
}
// 处方保存成功(刷新列表,并刷新编辑抽屉中的病历列表)
const onPrescriptionSuccess = () => {
loadData()
editRef.value?.refreshCaseList?.()
}
/** 与本预约关联的处方:已通过且未作废 → 与消费者处方「查看」同为只读预览 */
function prescriptionActionLabel(row: any) {
if (
row?.prescription_audit_status === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
}
return '开方'
}
// 开处方 / 查看已通过处方(只读)
const handlePrescription = (row: any) => {
//console.log(row)
if (!row.diagnosis_id && !row.patient_id) {
feedback.msgWarning('该预约缺少诊单信息,请先编辑患者')
return
}
prescriptionRef.value?.open(row)
}
// 查看病历(开方后显示)
const handleViewCase = async (row: any) => {
if (!row.id) {
feedback.msgWarning('预约信息不完整')
return
}
try {
const prescription = await prescriptionGetByAppointment({ appointment_id: row.id })
if (prescription?.id) {
prescriptionRef.value?.openById(prescription.id)
} else {
feedback.msgWarning('该预约暂无病历记录,请先开方')
}
} catch (error: any) {
feedback.msgError(error?.msg || '获取病历失败')
}
}
// 重新生成二维码
const handleRegenerateQRCode = () => {
if (currentQRCodeRow.value) {
handleMiniProgramQRCode(currentQRCodeRow.value)
}
}
// 默认:今天 + 待接诊
const _today = new Date()
const _pad = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${_pad(_today.getDate())}`
formData.end_date = formData.start_date
formData.status = 1
onMounted(() => {
loadData()
listPollTimer = setInterval(() => {
loadData({ silent: true })
}, LIST_POLL_MS)
})
onUnmounted(() => {
if (listPollTimer !== null) {
clearInterval(listPollTimer)
listPollTimer = null
}
})
</script>
<style scoped lang="scss">
.appointment-list {
min-height: 100%;
}
.list-card {
:deep(.el-card__body) {
padding: 0;
}
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.list-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__item) {
font-size: 14px;
padding: 0 16px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 500;
}
:deep(.el-tabs__ink-bar) {
height: 2px;
border-radius: 2px 2px 0 0;
}
:deep(.el-tabs__active-bar) {
height: 2px;
}
:deep(.el-tabs__nav) {
gap: 4px;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tab-badge {
margin-left: 4px;
:deep(.el-badge__content) {
font-size: 11px;
height: 16px;
line-height: 16px;
border: none;
}
}
.tab-badge-success :deep(.el-badge__content) {
background: var(--el-color-success);
}
.tab-badge-danger :deep(.el-badge__content) {
background: var(--el-color-danger);
}
.tab-badge-info :deep(.el-badge__content) {
background: var(--el-color-info);
}
.tab-badge-primary :deep(.el-badge__content) {
background: var(--el-color-primary);
}
.tab-priority {
font-weight: 500;
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.action-input {
width: 120px;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 32px;
padding: 12px 16px;
background: var(--el-fill-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
.filter-group {
display: flex;
align-items: center;
gap: 12px;
.filter-label {
font-size: 13px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.filter-radio {
:deep(.el-radio-button__inner) {
padding: 6px 14px;
}
}
}
}
.dropdown-item-with-input {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
.dropdown-label {
width: 40px;
flex-shrink: 0;
}
.dropdown-input {
width: 120px;
}
}
.table-wrap {
padding: 0 16px 20px;
}
.prescription-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
align-items: center;
}
.action-btns {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
row-gap: 6px;
column-gap: 8px;
.action-qrcode {
color: var(--el-color-warning) !important;
font-weight: 500;
&:hover {
color: var(--el-color-warning-dark-2) !important;
}
}
}
.appointment-table {
:deep(.el-table__header th) {
background: var(--el-fill-color-light);
font-weight: 500;
color: var(--el-text-color-regular);
}
:deep(.el-table__row) {
cursor: default;
}
/* 数据行纯白底(不使用 stripe / 行高亮色) */
:deep(.el-table__body tr > td) {
background-color: #ffffff !important;
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--el-fill-color-light) !important;
}
}
.patient-cell {
display: flex;
align-items: center;
gap: 12px;
}
.patient-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary-light-5), var(--el-color-primary-light-7));
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.patient-info {
min-width: 0;
}
.patient-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.patient-phone {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.patient-extra {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 2px;
}
.datetime-cell {
.date-text {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.time-text {
font-size: 13px;
font-weight: 500;
color: var(--el-color-primary);
margin-top: 4px;
}
}
.detail-content {
padding: 8px 0;
}
.detail-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.detail-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.el-icon {
font-size: 16px;
color: var(--el-color-primary);
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 24px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
.detail-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.detail-value {
font-size: 14px;
color: var(--el-text-color-primary);
}
}
.detail-item-full {
grid-column: 1 / -1;
}
.detail-remark {
font-size: 14px;
color: var(--el-text-color-regular);
margin: 0;
line-height: 1.6;
}
.text-danger {
color: var(--el-color-danger);
}
.complete-dialog-tip {
margin: 0 0 12px;
color: var(--el-text-color-primary);
font-size: 14px;
line-height: 22px;
}
.qrcode-content {
padding: 16px 0;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode-loading,
.qrcode-success,
.qrcode-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.qrcode-loading-text {
margin-top: 16px;
color: var(--el-text-color-secondary);
}
.qrcode-image-wrap {
padding: 12px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.qrcode-image {
width: 200px;
height: 200px;
display: block;
border-radius: 8px;
}
.qrcode-info {
margin-top: 16px;
font-size: 14px;
}
.qrcode-patient {
font-weight: 500;
color: var(--el-text-color-primary);
}
.qrcode-tip {
margin-top: 8px;
color: var(--el-text-color-regular);
}
.qrcode-path {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.qrcode-error {
color: var(--el-text-color-secondary);
gap: 12px;
}
</style>