Files
zyt/admin/src/views/tcm/diagnosis/index.vue
T
2026-04-21 18:02:27 +08:00

2030 lines
70 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="tcm-diagnosis">
<el-card class="filter-card !border-none" shadow="never">
<!-- 第一行日期 + 患者搜索 -->
<div class="filter-main">
<div class="date-chips">
<span
v-for="tab in dateTabs"
:key="tab.value"
:class="[
'chip',
{
active:
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.appointment_date === tab.value
}
]"
@click="handleDateTabClick(tab.value)"
>
{{ tab.label }}
<em v-if="tab.value && dateCounts[tab.value] !== undefined">{{ dateCounts[tab.value] }}</em>
</span>
<span
:class="['chip', 'chip-pending-booking', { active: formData.pending_booking === '1' }]"
@click="handlePendingBookingTabClick"
>
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<span
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
>
已完成
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
</span>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
v-perms="['tcm.diagnosis/assign']"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
</span>
</div>
<div class="search-row">
<el-input v-model="formData.keyword" placeholder="患者姓名 / 手机号" clearable class="search-input" @keyup.enter="doSearch" />
<el-button type="primary" @click="doSearch">查询</el-button>
</div>
</div>
<!-- 第二行挂号/确认 一键筛选无需下拉 -->
<div class="filter-chips">
<span class="chip-label">挂号</span>
<span
v-for="opt in hasAptOptions"
:key="opt.value"
:class="['chip', 'chip-sm', { active: formData.has_appointment === opt.value }]"
@click="setHasAppointment(opt.value)"
>
{{ opt.label }}
</span>
<span class="chip-divider" />
<span class="chip-label">确认</span>
<span
v-for="opt in confirmedOptions"
:key="opt.value"
:class="['chip', 'chip-sm', { active: formData.diagnosis_confirmed === opt.value }]"
@click="setConfirmed(opt.value)"
>
{{ opt.label }}
</span>
<el-button link type="primary" size="small" class="more-btn" @click="showMoreFilter = !showMoreFilter">
{{ showMoreFilter ? '收起' : '更多筛选' }}
<el-icon :class="{ 'rotate-180': showMoreFilter }"><ArrowDown /></el-icon>
</el-button>
</div>
<!-- 更多筛选折叠 -->
<div v-show="showMoreFilter" class="filter-more">
<el-select v-model="formData.diagnosis_type" placeholder="诊断类型" clearable size="small" @change="doSearch">
<el-option v-for="item in diagnosisTypeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-select v-model="formData.syndrome_type" placeholder="证型" clearable size="small" @change="doSearch">
<el-option v-for="item in syndromeTypeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-select v-model="formData.assistant_id" placeholder="医助" clearable filterable size="small" @change="doSearch">
<el-option v-for="item in assistantOptions" :key="item.id" :label="item.name" :value="Number(item.id)" />
</el-select>
<el-button size="small" @click="handleReset">重置</el-button>
</div>
</el-card>
<el-card class="!border-none mt-3 list-card" shadow="never">
<div class="list-header">
<div class="list-actions">
<el-button type="primary" @click="handleAdd" v-perms="['tcm.diagnosis/add']">新增诊单</el-button>
<el-button
v-perms="['tcm.diagnosis/assign']"
type="success"
@click="handleBatchAssign"
:disabled="selectedIds.length === 0"
>
批量指派医助
</el-button>
</div>
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} </div>
</div>
<div class="table-wrap px-4">
<el-table
:data="pager.lists"
size="default"
v-loading="pager.loading"
@selection-change="handleSelectionChange"
:row-class-name="getRowClassName"
class="diagnosis-table"
stripe
>
<el-table-column type="selection" width="48" align="center" />
<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>
</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="150">
<template #default="{ row }">
<div
class="appointment-cell"
:class="appointmentCellClasses(row)"
>
<template v-if="row.has_appointment">
<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>
</template>
</el-table-column>
<el-table-column label="确认" width="88" align="center">
<template #default="{ row }">
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
<span v-else class="status-unconfirmed">未确认</span>
</template>
</el-table-column>
<el-table-column label="复诊" min-width="120">
<template #default="{ row }">
<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="100" show-overflow-tooltip>
<template #default="{ row }">
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
</template>
</el-table-column>
<el-table-column label="开方" width="72" align="center">
<template #default="{ row }">
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
<template #default="{ row }">
<div class="video-watch-col">
<template v-if="canWatchCallEntry(row)">
<template v-if="watchCallShowEnterButton(row)">
<el-tooltip :content="watchCallEnterTooltip(row)" placement="top">
<span class="video-watch-trigger">
<el-button
type="primary"
link
size="small"
@click="onWatchCallEntryClick(row)"
>
进入旁观
</el-button>
</span>
</el-tooltip>
</template>
<span v-else class="video-watch-muted">{{ watchCallAssistantIdleText(row) }}</span>
</template>
<span v-else class="video-watch-muted">{{ watchCallPublicStatus(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="left">
<template #default="{ row }">
<div class="action-cell">
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
<el-button
v-perms="['tcm.diagnosis/guahao']"
type="success"
link
size="small"
@click="handleAppointment(row)"
>预约</el-button>
<el-button
v-perms="['tcm.diagnosis/edit']"
type="warning"
link
size="small"
@click="handleFillIdCard(row)"
>补全身份证</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleRowAction(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-if="hasPermission(['tcm.diagnosis/assign'])" command="assign"><el-icon><User /></el-icon>指派</el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></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="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
<detail-popup ref="detailRef" />
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
<assistant-watch-call-dialog
v-model="watchCallVisible"
:diagnosis-id="watchCallDiagnosisId"
@closed="getLists"
/>
<!-- 小程序二维码弹窗 -->
<el-dialog
v-model="qrcodeDialogVisible"
:title="qrcodeDialogTitle"
width="400px"
:close-on-click-modal="false"
>
<div class="flex flex-col items-center justify-center py-4">
<div v-if="qrcodeLoading" class="text-center">
<el-icon class="is-loading" :size="40">
<Loading />
</el-icon>
<div class="mt-4 text-gray-500">生成中...</div>
</div>
<div v-else-if="qrcodeUrl" class="text-center">
<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>
<div v-else class="text-center text-gray-500">
生成失败请重试
</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="assignDialogVisible"
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
width="500px"
>
<el-form :model="assignForm" label-width="100px">
<el-form-item label="选择医助" required>
<el-select
v-model="assignForm.assistant_id"
placeholder="请选择医助"
class="w-full"
filterable
clearable
value-key="id"
>
<el-option
v-for="item in assistantOptions"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
</el-form-item>
<el-form-item v-else label="诊单信息">
<div class="text-gray-500">
<div>患者{{ currentRow?.patient_name }}</div>
<div>诊断日期{{ currentRow?.diagnosis_date_text }}</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleConfirmAssign"
:loading="assignLoading"
>
确定指派
</el-button>
</template>
</el-dialog>
<!-- 创建订单弹窗 -->
<el-dialog
v-model="createOrderVisible"
title="创建订单"
width="600px"
@close="resetOrderForm"
>
<el-form
ref="orderFormRef"
:model="orderForm"
:rules="orderRules"
label-width="100px"
>
<el-form-item label="患者">
<span class="font-medium">{{ orderForm.patient_name }} ({{ maskPhone(orderForm.phone) }})</span>
</el-form-item>
<el-form-item label="订单类型" prop="order_type">
<el-select v-model="orderForm.order_type" placeholder="请选择" class="w-full">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" prop="amount">
<el-input-number
v-model="orderForm.amount"
:min="0"
:step="0.01"
:precision="2"
placeholder="请输入金额"
class="w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="orderForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createOrderVisible = false">取消</el-button>
<el-button type="primary" @click="submitOrder" :loading="orderLoading">创建订单</el-button>
</template>
</el-dialog>
<!-- 补全身份证弹窗 -->
<el-dialog
v-model="fillIdCardVisible"
title="补全身份证号"
width="400px"
:close-on-click-modal="false"
@close="fillIdCardForm.id_card = ''"
>
<el-form ref="fillIdCardFormRef" :model="fillIdCardForm" :rules="fillIdCardRules" label-width="80px">
<el-form-item label="患者">
<span class="font-medium">{{ fillIdCardForm.patient_name }}</span>
</el-form-item>
<el-form-item label="身份证号" prop="id_card">
<el-input
v-model="fillIdCardForm.id_card"
placeholder="请输入15或18位身份证号"
maxlength="18"
show-word-limit
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fillIdCardVisible = false">取消</el-button>
<el-button type="primary" @click="submitFillIdCard" :loading="fillIdCardLoading">提交自动更新年龄</el-button>
</template>
</el-dialog>
<!-- 企业微信聊天记录抽屉 -->
<el-drawer
v-model="wechatDrawerVisible"
:title="`企业微信记录 - ${wechatCurrentPatient?.patient_name || ''}`"
size="600px"
:destroy-on-close="true"
>
<!-- 患者与企微信息 -->
<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="手机号">{{ 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>
</el-descriptions-item>
<el-descriptions-item label="会话存档">
<el-tag v-if="wechatContactInfo.msgaudit_enabled" type="success" size="small">已开通</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 存档成员列表 -->
<div v-if="wechatContactInfo.permit_users?.length" class="mt-2">
<span class="text-sm text-gray-500 mr-2">存档成员</span>
<el-tag
v-for="uid in wechatContactInfo.permit_users"
:key="uid"
size="small"
class="mr-1"
>
{{ uid }}
</el-tag>
</div>
<!-- 同意存档情况 -->
<div v-if="wechatContactInfo.msgaudit_agree?.length" class="mt-2">
<span class="text-sm text-gray-500 mr-2">存档同意</span>
<el-tag
v-for="(info, idx) in wechatContactInfo.msgaudit_agree"
:key="idx"
:type="info.agree_status === 'Agree' ? 'success' : 'warning'"
size="small"
class="mr-1"
>
{{ info.userid }}: {{ info.agree_status === 'Agree' ? '已同意' : info.agree_status }}
</el-tag>
</div>
</div>
<el-divider />
<!-- 添加记录 -->
<div class="mb-4">
<div class="flex gap-2">
<el-input
v-model="wechatNewNote"
type="textarea"
:rows="2"
placeholder="添加沟通记录..."
class="flex-1"
/>
<el-button type="primary" @click="handleAddRecord" :loading="wechatAddLoading" class="self-end">
添加
</el-button>
</div>
</div>
<!-- 记录列表 -->
<div v-loading="wechatRecordsLoading">
<div v-if="wechatRecords.length === 0" class="text-center text-gray-400 py-10">
暂无聊天记录
</div>
<div v-else class="record-list">
<div
v-for="record in wechatRecords"
:key="record.id"
class="record-item"
>
<div class="record-header">
<div class="flex items-center gap-2">
<el-tag :type="record.direction === 0 ? 'primary' : 'success'" size="small">
{{ record.direction === 0 ? '员工' : '客户' }}
</el-tag>
<span class="font-medium text-sm">
{{ record.direction === 0 ? record.staff_name : record.external_name || wechatCurrentPatient?.patient_name }}
</span>
<el-tag v-if="record.msg_type === 'note'" type="warning" size="small">备注</el-tag>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ formatTimestamp(record.chat_time) }}</span>
<el-button type="danger" link size="small" @click="handleDeleteRecord(record.id)">
删除
</el-button>
</div>
</div>
<div class="record-content">{{ record.content }}</div>
<div v-if="record.media_url" class="mt-1">
<el-image :src="record.media_url" style="max-width: 200px" fit="contain" :preview-src-list="[record.media_url]" />
</div>
</div>
</div>
<div v-if="wechatRecordTotal > wechatRecords.length" class="text-center mt-4">
<el-button link type="primary" @click="loadMoreRecords">加载更多</el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts" name="tcmDiagnosis">
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
import { orderCreate } from '@/api/order'
import { cancelAppointment } from '@/api/doctor'
import { usePaging } from '@/hooks/usePaging'
import { getDictData } from '@/api/app'
import { getWeappConfig } from '@/api/channel/weapp'
import feedback from '@/utils/feedback'
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 { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
const AppointmentPopup = defineAsyncComponent(() => import('./appointment.vue'))
const AssistantWatchCallDialog = defineAsyncComponent(() => import('./components/AssistantWatchCallDialog.vue'))
// 格式化时间戳为日期时间
const formatDateTime = (timestamp: number | string) => {
if (!timestamp) return '-'
// 如果是秒级时间戳(10位),转换为毫秒级
const ts = String(timestamp).length === 10 ? Number(timestamp) * 1000 : Number(timestamp)
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({
keyword: '',
diagnosis_type: '',
syndrome_type: '',
assistant_id: '',
start_time: '',
end_time: '',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
pending_assign: '' as '' | '1',
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
pending_booking: '' as '' | '1',
/** 顶部「已完成」Tab:至少有一条挂号记录为已完成 status=3 */
completed_appointment: '' as '' | '1'
})
const activeTab = ref('all')
const showMoreFilter = ref(false)
const hasAptOptions = [
{ label: '全部', value: '' },
{ label: '已挂号', value: '1' },
{ label: '未挂号', value: '0' }
]
const confirmedOptions = [
{ label: '全部', value: '' },
{ label: '已确认', value: '1' },
{ label: '未确认', value: '0' }
]
const setHasAppointment = (v: string) => {
formData.has_appointment = v as '' | '0' | '1'
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
const setConfirmed = (v: string) => {
formData.diagnosis_confirmed = v as '' | '0' | '1'
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
const doSearch = () => {
pager.page = 1
getLists()
fetchDateCounts()
}
// 重点:当天/明天/后天挂号 Tab
const dateTabs = computed(() => [
{ label: '当天挂号', value: todayStr.value },
{ label: '明天挂号', value: tomorrowStr.value },
{ label: '后天挂号', value: dayAfterStr.value },
{ label: '全部', value: '' }
])
const dateCounts = ref<Record<string, number>>({})
const pendingBookingCount = ref<number | undefined>(undefined)
const completedVisitCount = ref<number | undefined>(undefined)
const pendingAssignCount = ref<number | undefined>(undefined)
const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = value
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
const handlePendingAssignTabClick = async () => {
formData.pending_assign = '1'
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
// 获取各日期挂号数量 + 待预约 + 已完成 + 待分配医助数量
const fetchDateCounts = async () => {
try {
const [today, tomorrow, dayAfter, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
])
dateCounts.value = {
[todayStr.value]: today?.count ?? 0,
[tomorrowStr.value]: tomorrow?.count ?? 0,
[dayAfterStr.value]: dayAfter?.count ?? 0
}
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
}
/** 首屏优先主列表,错峰拉各日 tab 条数,减轻与 lists 并发争抢 */
function scheduleDeferredDateCounts() {
const run = () => fetchDateCounts()
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, { timeout: 2500 })
} else {
setTimeout(run, 120)
}
}
const SILENT_LIST_POLL_MS = 10_000
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
function silentRefreshListsAndDateCounts() {
if (typeof document !== 'undefined' && document.hidden) return
void getLists({ silent: true }).catch(() => {})
void fetchDateCounts()
}
// 日期快捷选项(computed 保证每日刷新)
const pad = (n: number) => String(n).padStart(2, '0')
const todayStr = computed(() => {
const t = new Date()
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const tomorrowStr = computed(() => {
const t = new Date()
t.setDate(t.getDate() + 1)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const dayAfterStr = computed(() => {
const t = new Date()
t.setDate(t.getDate() + 2)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
// Tab 切换
const handleTabChange = (tabName: string | number) => {
formData.diagnosis_confirmed = ''
formData.has_appointment = ''
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
if (tabName === 'unconfirmed') {
formData.diagnosis_confirmed = '0'
} else if (tabName === 'confirmed') {
formData.diagnosis_confirmed = '1'
} else if (tabName === 'no_appointment') {
formData.has_appointment = '0'
}
pager.page = 1
getLists()
}
// 更多下拉
const handleMoreCommand = (cmd: string) => {
if (cmd === 'reset') handleReset()
}
// 获取字典选项
const diagnosisTypeOptions = ref<any[]>([])
const syndromeTypeOptions = ref<any[]>([])
const assistantOptions = ref<any[]>([])
const getDictOptions = async () => {
try {
const [diagnosisType, syndromeType, assistants] = await Promise.all([
getDictData({ type: 'diagnosis_type' }),
getDictData({ type: 'syndrome_type' }),
getAssistants()
])
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
assistantOptions.value = assistants || []
} catch (error) {
console.error('获取字典数据失败:', error)
}
}
// 获取字典标签
const getDictLabel = (options: any[], value: string) => {
const item = options.find(opt => opt.value === value)
return item ? item.name : value || '-'
}
// 获取医助名称
const getAssistantName = (assistantId: number | string) => {
// 如果没有assistant_id,返回"-"
if (!assistantId && assistantId !== 0) return '-'
// 确保类型匹配,转换为数字进行比较
const id = typeof assistantId === 'string' ? parseInt(assistantId) : assistantId
// 如果医助列表还没加载,返回"加载中..."
if (assistantOptions.value.length === 0) return '...'
const assistant = assistantOptions.value.find(item => {
const itemId = typeof item.id === 'string' ? parseInt(item.id) : item.id
return itemId === id
})
return assistant ? assistant.name : '-'
}
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: tcmDiagnosisLists,
params: formData
})
/** 弹窗内保存后刷新列表:静默请求,避免 el-table v-loading 白蒙层盖住仍打开的抽屉 */
const refreshListAfterPopupSave = () => getLists({ silent: true })
// 重置
const handleReset = () => {
activeTab.value = 'all'
formData.keyword = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
// 是否已确认诊单
const isDiagnosisConfirmed = (row: any) => {
const records = row.DiagnosisViewRecord || []
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
// 行高亮:未确认诊单优先,其次未挂号
const getRowClassName = ({ row }: { row: any }) => {
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
if (!row.has_appointment) return 'row-no-appointment'
return ''
}
const editRef = ref()
const detailRef = ref()
const appointmentRef = ref()
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
const pendingRouteDiagnosisId = ref<number | null>(null)
const userStore = useUserStore()
watch(
[editRef, pendingRouteDiagnosisId],
() => {
const id = pendingRouteDiagnosisId.value
const opener = editRef.value as { open?: (mode: string, diagnosisId: number) => void } | undefined
if (id == null || !opener?.open) return
opener.open('edit', id)
pendingRouteDiagnosisId.value = null
},
{ flush: 'post' }
)
const watchCallVisible = ref(false)
const watchCallDiagnosisId = ref(0)
const isAssignedAssistant = (row: any) => {
const aid = row.assistant_id ?? row.assistant
if (aid === null || aid === undefined || aid === '') return false
return Number(aid) === Number(userStore.userInfo?.id)
}
const openWatchCall = (row: any) => {
watchCallDiagnosisId.value = Number(row.id) || 0
watchCallVisible.value = true
}
type VideoCallHint = {
state: string
label: string
start_time?: number
end_time?: number
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const watchCallLiveTooltip = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined
const base = '点击进入实时房间(仅观看,不推流、不上麦)'
if (h?.start_time) {
return `${base}。开始时间:${formatDateTime(h.start_time)}`
}
return base
}
/** 通话进行中或已发起待同步房间时,都显示「进入旁观」入口(避免仅 live 才有按钮导致看不见) */
const watchCallShowEnterButton = (row: any) => {
const st = watchCallState(row)
return st === 'live' || st === 'pending_room'
}
const watchCallEnterTooltip = (row: any) => {
if (watchCallState(row) === 'pending_room') {
return '医生尚未接通或未同步房间号,接通后再点此进入'
}
return watchCallLiveTooltip(row)
}
const onWatchCallEntryClick = (row: any) => {
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
return
}
openWatchCall(row)
}
const watchCallAssistantIdleText = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined
if (h?.label) return h.label
return '暂无可旁观通话'
}
const watchCallPublicStatus = (row: any) => {
const st = watchCallState(row)
if (st === 'none') return '—'
if (st === 'live') return '通话中'
if (st === 'pending_room') return '接通中'
const h = row.video_call_hint as VideoCallHint | undefined
return h?.label || '—'
}
// 创建订单相关
const createOrderVisible = ref(false)
const orderFormRef = ref()
const orderLoading = ref(false)
const orderForm = reactive({
patient_id: '' as string | number,
patient_name: '',
phone: '',
order_type: '' as string | number,
amount: 0,
remark: '',
has_appointment: false,
appointment_time_text: ''
})
const orderRules = {
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
amount: [{ required: true, message: '请输入订单金额', trigger: 'blur' }]
}
const handleCreateOrder = (row: any) => {
orderForm.patient_id = row.patient_id || row.id
orderForm.patient_name = row.patient_name
orderForm.phone = row.phone || ''
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
}
const resetOrderForm = () => {
orderForm.patient_id = ''
orderForm.patient_name = ''
orderForm.phone = ''
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
orderForm.has_appointment = false
orderForm.appointment_time_text = ''
orderFormRef.value?.clearValidate()
}
const submitOrder = async () => {
try {
await orderFormRef.value?.validate()
orderLoading.value = true
const res = await orderCreate({
patient_id: orderForm.patient_id,
order_type: orderForm.order_type,
amount: orderForm.amount,
remark: orderForm.remark
})
createOrderVisible.value = false
getLists()
const orderNo = res?.order_no
if (orderNo) {
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
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) {
qrcodeUrl.value = qrRes.qrcode_url
} else {
feedback.msgError('二维码生成失败')
}
} catch (e: any) {
feedback.msgError(e?.msg || '生成二维码失败')
} finally {
qrcodeLoading.value = false
}
}
} catch (error) {
console.error('创建订单失败:', error)
} finally {
orderLoading.value = false
}
}
// 小程序二维码相关
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')
// 批量指派相关
const selectedIds = ref<number[]>([])
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const assignMode = ref<'batch' | 'single'>('batch')
const currentRow = ref<any>(null)
const assignForm = ref({
assistant_id: null as number | null
})
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map(item => item.id)
}
// 单独指派
const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
// 确保类型一致,转换为数字
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
// 批量指派
const handleBatchAssign = () => {
if (selectedIds.value.length === 0) {
feedback.msgWarning('请先选择要指派的诊单')
return
}
assignMode.value = 'batch'
currentRow.value = null
assignForm.value.assistant_id = null
assignDialogVisible.value = true
}
const handleConfirmAssign = async () => {
if (!assignForm.value.assistant_id) {
feedback.msgWarning('请选择医助')
return
}
assignLoading.value = true
try {
if (assignMode.value === 'single') {
// 单独指派
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id
})
} else {
// 批量指派
const promises = selectedIds.value.map(id =>
tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id
})
)
await Promise.all(promises)
selectedIds.value = []
}
assignDialogVisible.value = false
getLists()
} catch (error) {
console.error('指派失败:', error)
} finally {
assignLoading.value = false
}
}
const handleAdd = () => {
editRef.value.open('add')
}
const handleEdit = (id: number) => {
editRef.value.open('edit', id)
}
const handleDetail = (id: number) => {
detailRef.value.open(id)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该诊单吗?')
await tcmDiagnosisDelete({ id })
getLists()
}
// 行操作下拉
const handleRowAction = (cmd: string, row: any) => {
switch (cmd) {
case 'assign': handleSingleAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
handleVideoQRCode(row)
break
case 'confirmQr': handleMiniProgramQRCode(row); break
case 'cancelApt': handleCancelAppointment(row); break
case 'order': handleCreateOrder(row); break
case 'delete': handleDelete(row.id); break
}
}
/** 挂号状态:1=已预约 3=已完成 4=已过号(与后端一致) */
const appointmentStatusLabel = (row: any) => {
const s = Number(row.appointment_status)
if (s === 3) return '已完成'
if (s === 4) return '已过号'
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 {
'has-apt': row.has_appointment,
'apt-row-done': row.has_appointment && s === 3,
'apt-row-missed': row.has_appointment && s === 4
}
}
/** 仅已预约(1)可进视频/小程序码 */
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/** 已预约、已过号可取消(后端同步限制) */
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
}
// 取消挂号
const handleCancelAppointment = async (row: any) => {
if (!canCancelAppointmentRow(row)) {
const s = Number(row.appointment_status)
if (row.has_appointment && s === 3) {
feedback.msgWarning('已完成就诊,无法取消挂号')
} else {
feedback.msgWarning('该诊单未挂号或挂号信息异常')
}
return
}
try {
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
await cancelAppointment({ id: row.appointment_id })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
// 患者挂号
const handleAppointment = (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
appointmentRef.value?.open(row)
}
// 补全身份证
const fillIdCardVisible = ref(false)
const fillIdCardLoading = ref(false)
const fillIdCardFormRef = ref()
const fillIdCardForm = reactive({
id: 0,
patient_name: '',
id_card: ''
})
const validateIdCard = (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value) {
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(value)
const valid15 = /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/.test(value)
if (valid18 || valid15) {
callback()
} else {
callback(new Error('请输入15或18位有效身份证号'))
}
}
const fillIdCardRules = {
id_card: [{ required: true, validator: validateIdCard, trigger: 'blur' }]
}
const handleFillIdCard = (row: any) => {
fillIdCardForm.id = row.id
fillIdCardForm.patient_name = row.patient_name || '-'
fillIdCardForm.id_card = row.id_card || ''
fillIdCardVisible.value = true
}
const submitFillIdCard = async () => {
await fillIdCardFormRef.value?.validate()
fillIdCardLoading.value = true
try {
await fillIdCard({ id: fillIdCardForm.id, id_card: fillIdCardForm.id_card })
feedback.msgSuccess('补全成功,年龄已自动更新')
fillIdCardVisible.value = false
getLists()
} catch (e: any) {
feedback.msgError(e?.msg || '补全失败')
} finally {
fillIdCardLoading.value = false
}
}
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
qrcodeDialogTitle.value = '视频二维码'
try {
const config = await getWeappConfig()
if (!config?.app_id) {
feedback.msgError('小程序未配置,请先配置小程序信息')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
if (result?.qrcode_url) {
qrcodeUrl.value = result.qrcode_url
} else {
feedback.msgError('二维码生成失败')
}
} catch (error: any) {
feedback.msgError(error?.msg || '生成二维码失败,请重试')
} finally {
qrcodeLoading.value = false
}
}
// 生成确认诊单二维码
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
qrcodeDialogTitle.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.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
share_user_id: currentUser?.id || ''
})
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 handleRegenerateQRCode = () => {
if (currentQRCodePatient.value) {
lastQRCodeType.value === 'video'
? handleVideoQRCode(currentQRCodePatient.value)
: handleMiniProgramQRCode(currentQRCodePatient.value)
}
}
// ========== 企业微信聊天记录 ==========
const wechatDrawerVisible = ref(false)
const wechatCurrentPatient = ref<any>(null)
const wechatRecords = ref<any[]>([])
const wechatRecordTotal = ref(0)
const wechatRecordsLoading = ref(false)
const wechatContactInfo = ref<any>(null)
const wechatNewNote = ref('')
const wechatAddLoading = ref(false)
const wechatPage = ref(1)
const formatTimestamp = (ts: number) => {
if (!ts) return '-'
const t = String(ts).length === 10 ? ts * 1000 : ts
return dayjs(t).format('YYYY-MM-DD HH:mm')
}
const handleWechatRecords = async (row: any) => {
wechatCurrentPatient.value = row
wechatDrawerVisible.value = true
wechatRecords.value = []
wechatRecordTotal.value = 0
wechatContactInfo.value = null
wechatNewNote.value = ''
wechatPage.value = 1
// 并行加载聊天记录和外部联系人信息
wechatRecordsLoading.value = true
try {
const [recordsRes, contactRes] = await Promise.all([
getWechatChatRecords({ diagnosis_id: row.id, patient_id: row.patient_id, page_no: 1, page_size: 20 }),
getWechatExternalContact({ patient_id: row.patient_id || row.id }).catch(() => null)
])
wechatRecords.value = recordsRes?.lists || []
wechatRecordTotal.value = recordsRes?.count || 0
wechatContactInfo.value = contactRes
} catch (e) {
console.error('加载企业微信记录失败:', e)
} finally {
wechatRecordsLoading.value = false
}
}
const loadMoreRecords = async () => {
if (!wechatCurrentPatient.value) return
wechatPage.value++
try {
const res = await getWechatChatRecords({
diagnosis_id: wechatCurrentPatient.value.id,
patient_id: wechatCurrentPatient.value.patient_id,
page_no: wechatPage.value,
page_size: 20
})
wechatRecords.value.push(...(res?.lists || []))
} catch (e) {
console.error('加载更多记录失败:', e)
}
}
const handleAddRecord = async () => {
if (!wechatNewNote.value.trim()) {
feedback.msgWarning('请输入内容')
return
}
wechatAddLoading.value = true
try {
await addWechatChatRecord({
diagnosis_id: wechatCurrentPatient.value.id,
patient_id: wechatCurrentPatient.value.patient_id,
content: wechatNewNote.value.trim(),
msg_type: 'note',
staff_name: userStore.userInfo?.name || '',
external_name: wechatCurrentPatient.value.patient_name || '',
})
wechatNewNote.value = ''
// 重新加载记录
handleWechatRecords(wechatCurrentPatient.value)
} catch (e: any) {
feedback.msgError(e?.msg || '添加失败')
} finally {
wechatAddLoading.value = false
}
}
const handleDeleteRecord = async (id: number) => {
await feedback.confirm('确定删除该记录吗?')
try {
await deleteWechatChatRecord({ id })
wechatRecords.value = wechatRecords.value.filter(r => r.id !== id)
wechatRecordTotal.value--
} catch (e: any) {
feedback.msgError(e?.msg || '删除失败')
}
}
const route = useRoute()
onMounted(() => {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
getDictOptions()
getLists()
scheduleDeferredDateCounts()
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
const raw = route.query.id
if (raw != null && raw !== '') {
const num = Number(raw)
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
}
})
onUnmounted(() => {
if (silentListPollTimer != null) {
clearInterval(silentListPollTimer)
silentListPollTimer = null
}
})
</script>
<style lang="scss" scoped>
.tcm-diagnosis {
min-height: 100%;
background: var(--el-bg-color-page);
margin: 0 auto;
}
.filter-card {
:deep(.el-card__body) {
padding: 12px 16px;
}
}
.filter-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.date-chips {
display: flex;
gap: 6px;
.chip {
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
em {
font-style: normal;
margin-left: 4px;
opacity: 0.8;
}
&:hover {
background: var(--el-fill-color);
}
&.active {
color: #fff;
background: var(--el-color-primary);
}
}
.chip-pending-booking {
margin-left: 8px;
color: var(--el-color-info);
background: var(--el-color-info-light-9);
&:hover {
background: var(--el-color-info-light-8);
}
&.active {
color: #fff;
background: var(--el-color-info);
}
}
.chip-completed-visit {
margin-left: 8px;
color: var(--el-color-success);
background: var(--el-color-success-light-9);
&:hover {
background: var(--el-color-success-light-8);
}
&.active {
color: #fff;
background: var(--el-color-success);
}
}
.chip-pending {
margin-left: 8px;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
&:hover {
background: var(--el-color-warning-light-8);
}
&.active {
color: #fff;
background: var(--el-color-warning);
}
}
}
.search-row {
display: flex;
gap: 8px;
.search-input {
width: 160px;
}
}
.filter-chips {
display: flex;
align-items: center;
gap: 6px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
.chip-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-right: 2px;
}
.chip {
padding: 3px 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.active {
color: var(--el-color-primary);
font-weight: 500;
background: var(--el-color-primary-light-9);
}
}
.chip-sm {
padding: 4px 10px;
}
.chip-divider {
width: 1px;
height: 14px;
background: var(--el-border-color-lighter);
margin: 0 4px;
}
.more-btn {
margin-left: 8px;
:deep(.el-icon.rotate-180) {
transform: rotate(180deg);
}
}
}
.filter-more {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.list-card {
:deep(.el-card__body) {
padding: 0;
}
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
.list-actions {
display: flex;
gap: 8px;
}
.list-selected {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.table-wrap {
padding: 10px 16px 0;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 10px 16px 16px;
}
.patient-cell {
display: flex;
align-items: center;
gap: 8px;
.patient-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
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: 600;
font-size: 14px;
color: var(--el-text-color-primary);
line-height: 1.3;
}
.patient-meta {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 2px;
}
}
.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;
.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;
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 2px;
}
.apt-doctor {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.apt-time {
font-size: 12px;
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-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-none {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
}
.status-confirmed {
font-size: 13px;
color: var(--el-color-success);
font-weight: 500;
}
.status-unconfirmed {
font-size: 13px;
color: var(--el-color-warning);
font-weight: 600;
}
.status-prescribed {
font-size: 13px;
color: #4A5DFF;
font-weight: 500;
}
.status-unprescribed {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.diagnosis-cell {
display: flex;
flex-direction: column;
gap: 2px;
.diagnosis-date {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.diagnosis-extra {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.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);
}
.action-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.video-watch-col {
font-size: 12px;
line-height: 1.4;
padding: 0 2px;
}
/* el-tooltip 单个子节点为行内按钮时,包一层避免触发区域为 0 或错位 */
.video-watch-trigger {
display: inline-block;
vertical-align: middle;
}
.video-watch-muted {
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
}
.diagnosis-table {
:deep(.el-table__header th) {
background: var(--el-fill-color-light);
font-weight: 600;
font-size: 13px;
color: var(--el-text-color-regular);
}
:deep(.el-table__row) {
cursor: default;
}
:deep(.el-table__body .el-table__cell) {
padding: 8px 0;
}
:deep(.el-table__row.row-unconfirmed) {
border-left: 3px solid var(--el-color-warning);
background: linear-gradient(90deg, rgba(var(--el-color-warning-rgb), 0.06) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-unconfirmed:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-warning-rgb), 0.1) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-no-appointment) {
border-left: 3px solid var(--el-color-info);
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.04) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-no-appointment:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
}
}
.record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-item {
background: #f9fafb;
border-radius: 8px;
padding: 12px;
border: 1px solid #f0f0f0;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.record-content {
font-size: 14px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-all;
}
</style>