Files
zyt/admin/src/views/tcm/diagnosis/index.vue
T
2026-05-08 15:20:02 +08:00

2522 lines
90 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="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="pending-assign-wrap"
v-perms="['tcm.diagnosis/assign']"
>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
</span>
<el-date-picker
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_order_month"
type="month"
placeholder="订单月份"
value-format="YYYY-MM"
clearable
class="pending-assign-month"
@change="onPendingAssignOrderMonthChange"
/>
<el-input
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_keyword"
placeholder="搜身份证/诊单ID/患者号/备注…"
clearable
class="pending-assign-search"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</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>
<el-button
v-perms="['tcm.diagnosis/assign']"
type="warning"
plain
@click="handleBatchCancelAssign"
:disabled="!canBatchCancelAssign"
>
批量取消指派
</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"
row-key="id"
size="default"
v-loading="pager.loading"
@selection-change="handleSelectionChange"
@row-dblclick="goReadonly"
: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="175">
<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 v-if="canCancelAppointmentItem(apt)" class="apt-actions">
<el-button
v-perms="['tcm.diagnosis/guahao']"
type="danger"
link
size="small"
@click.stop="handleCancelAppointmentItem(row, apt)"
>
取消挂号
</el-button>
</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="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
placement="top"
>
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
{{ row.unserved_days ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-days unserved-muted"></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="340" fixed="right" align="left">
<template #default="{ row }">
<div class="action-cell" @click.stop @dblclick.stop>
<el-button
type="primary"
link
size="small"
@click="goReadonly(row)"
v-perms="['tcm.diagnosis/readonlyDetail']"
>
查看
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
type="primary"
link
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</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="hasPermission(['tcm.diagnosis/assign']) && hasAssignedAssistant(row)"
command="cancelAssign"
>
<el-icon><Remove /></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="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt"
>
<el-icon><CircleClose /></el-icon>取消挂号
</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahaoLogList'])" command="guahaoLogs"><el-icon><List /></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" />
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
<appointment-popup ref="appointmentRef" @success="handleAppointmentSaved" />
<assistant-watch-call-dialog
v-model="watchCallVisible"
:diagnosis-id="watchCallDiagnosisId"
@closed="getLists"
/>
<el-drawer v-model="guahaoLogDrawerVisible" title="挂号操作日志" size="460px" destroy-on-close @closed="resetGuahaoLogDrawer">
<div v-loading="guahaoLogLoading" class="min-h-[100px]">
<div v-if="guahaoLogContextRow" class="text-sm text-gray-600 mb-4 pb-3 border-b border-gray-100">
<span class="font-medium text-gray-800">{{ guahaoLogContextRow.patient_name }}</span>
<span class="mx-2 text-gray-300">|</span>
<span>诊单 #{{ guahaoLogContextRow.id }}</span>
</div>
<el-empty v-if="!guahaoLogLoading && guahaoLogRows.length === 0" description="暂无挂号/取消挂号操作记录" />
<el-timeline v-else-if="guahaoLogRows.length > 0">
<el-timeline-item
v-for="(log, idx) in guahaoLogRows"
:key="log.id ?? idx"
:timestamp="log.create_time_text || '—'"
placement="top"
>
<div class="flex flex-wrap items-center gap-2">
<el-tag size="small" type="info">{{ log.action_desc }}</el-tag>
<span class="text-sm font-medium text-gray-800">{{ formatGuahaoLogOperator(log) }}</span>
</div>
<div class="text-xs text-gray-600 mt-1.5 leading-relaxed">{{ log.summary }}</div>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
<!-- 小程序二维码弹窗 -->
<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"
@closed="resetAssignDialog"
>
<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
>
<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">{{ batchAssignIds.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-option label="驼奶费用" :value="8" />
</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, tcmDiagnosisGuahaoLogList, 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, Remove } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { useRoute, useRouter } 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 TcmPrescription = defineAsyncComponent(() => import('@/components/tcm-prescription/index.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',
/** 待分配医助:按业务订单创建月份筛选 YYYY-MM,空表示不限月份 */
pending_assign_order_month: '' as string,
/** 待分配医助 Tab:多字段检索(后端扩展至身份证/诊单ID/备注等;不额外套数据权限) */
pending_assign_keyword: '' as string
})
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()
}
/** 待分配 + 宽搜时:请求只吃分页与关键词,不带其它 Tab/日期/筛选(与 buildTcmDiagnosisListRequestPayload 一致) */
function isPendingAssignWideKeywordParams(req: Record<string, unknown>): boolean {
const pa = req.pending_assign === '1' || req.pending_assign === 1
if (!pa) {
return false
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
return k1 !== '' || k2 !== ''
}
/** 待分配宽搜:仅传分页 + pending_assign + 关键词,丢弃其余查询参数 */
function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Record<string, unknown> {
if (!isPendingAssignWideKeywordParams(req)) {
return req
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
const kw = k1 !== '' ? k1 : k2
return {
page_no: req.page_no,
page_size: req.page_size,
pending_assign: 1,
pending_assign_keyword: kw
}
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
/** 宽搜提交前清理界面上的其它检索,避免 Chip 仍显示「已选」但与实际请求不一致 */
function clearSecondaryFiltersWhenPendingAssignWideSearch() {
if (formData.pending_assign !== '1') {
return
}
const kw1 = (formData.pending_assign_keyword ?? '').trim()
const kw2 = (formData.keyword ?? '').trim()
if (kw1 === '' && kw2 === '') {
return
}
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.diagnosis_confirmed = ''
formData.has_appointment = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.start_time = ''
formData.end_time = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
formData.keyword = ''
}
}
const doSearch = () => {
clearSecondaryFiltersWhenPendingAssignWideSearch()
pager.page = 1
getLists()
fetchDateCounts()
}
// 重点:当天/明天/后天挂号 Tab
const dateTabs = computed(() => [
{ label: '昨天挂号', value: yesterdayStr.value },
{ label: '前天挂号', value: dayBeforeStr.value },
{ 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_assign_order_month = ''
formData.pending_assign_keyword = ''
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_assign_order_month = ''
formData.pending_assign_keyword = ''
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_assign_order_month = ''
formData.pending_assign_keyword = ''
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 = ''
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
}
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 待分配:切换订单月份后刷新列表与角标 */
const onPendingAssignOrderMonthChange = async (val: string | null) => {
formData.pending_assign_order_month = val || ''
if (formData.pending_assign !== '1') return
pager.page = 1
await getLists()
fetchDateCounts()
}
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
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({ 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(
buildTcmDiagnosisListRequestPayload({
pending_assign: 1,
page_no: 1,
page_size: 1,
pending_assign_order_month: formData.pending_assign_order_month,
pending_assign_keyword: formData.pending_assign_keyword,
keyword: formData.keyword
}) as any
)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
[dayBeforeStr.value]: dayBefore?.count ?? 0,
[todayStr.value]: today?.count ?? 0,
[tomorrowStr.value]: tomorrow?.count ?? 0,
[dayAfterStr.value]: dayAfter?.count ?? 0,
'': all?.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())}`
})
const yesterdayStr = computed(() => {
const t = new Date()
t.setDate(t.getDate() - 1)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const dayBeforeStr = 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: fetchTcmDiagnosisListsForPaging,
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_assign_order_month = ''
formData.pending_assign_keyword = ''
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)
}
/**
* 「未服务天数」着色规则(计划 T3):
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
*/
const unservedDaysClass = (n: unknown) => {
if (n === null || n === undefined) return 'unserved-muted'
const num = Number(n)
if (!Number.isFinite(num)) return 'unserved-muted'
if (num >= 7) return 'unserved-red'
if (num >= 3) return 'unserved-orange'
return 'unserved-green'
}
// 行高亮:未确认诊单优先,其次未挂号
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 prescriptionRef = 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[]>([])
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
const batchAssignIds = 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
})
function resetAssignDialog() {
batchAssignIds.value = []
assignForm.value.assistant_id = null
}
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map(item => item.id)
}
/** 当前行是否已指派医助(用于取消指派入口) */
const hasAssignedAssistant = (row: any) => {
const id = Number(row?.assistant_id ?? row?.assistant ?? 0)
return id > 0
}
/** 当前页勾选记录中是否存在已指派医助的诊单(批量取消指派) */
const canBatchCancelAssign = computed(() => {
if (!selectedIds.value.length) return false
const set = new Set(selectedIds.value)
return pager.lists.some((r: any) => set.has(r.id) && hasAssignedAssistant(r))
})
// 单独指派
const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
batchAssignIds.value = []
// 确保类型一致,转换为数字
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
batchAssignIds.value = [...selectedIds.value]
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 {
// 批量指派:使用打开弹窗时的 id 快照(避免弹窗内操作导致表格勾选被清空后提交为空)
const ids = batchAssignIds.value
if (!ids.length) {
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
return
}
for (const id of ids) {
await tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id!,
})
}
selectedIds.value = []
batchAssignIds.value = []
}
assignDialogVisible.value = false
getLists()
} catch (error) {
console.error('指派失败:', error)
} finally {
assignLoading.value = false
}
}
/** 取消指派:将 assistant_id 置为 0(与后端 assign 一致) */
const handleCancelAssign = async (row: any) => {
if (!row?.id || !hasAssignedAssistant(row)) {
feedback.msgWarning('该诊单未指派医助')
return
}
await feedback.confirm('确定取消该诊单的医助指派吗?')
await tcmDiagnosisAssign({ id: row.id, assistant_id: 0 })
getLists()
}
const handleBatchCancelAssign = async () => {
if (!selectedIds.value.length) {
feedback.msgWarning('请先选择诊单')
return
}
const set = new Set(selectedIds.value)
const rows = pager.lists.filter((r: any) => set.has(r.id) && hasAssignedAssistant(r))
if (!rows.length) {
feedback.msgWarning('所选诊单中没有已指派医助的记录')
return
}
await feedback.confirm(`确定取消 ${rows.length} 条诊单的医助指派吗?`)
await Promise.all(rows.map((r: any) => tcmDiagnosisAssign({ id: r.id, assistant_id: 0 })))
selectedIds.value = []
getLists()
}
const handleAdd = () => {
editRef.value.open('add')
}
const handleEdit = (id: number) => {
editRef.value.open('edit', id)
}
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
function prescriptionActionLabel(row: any) {
if (formData.pending_assign === '1') {
return '开方'
}
if (
Number(row?.prescription_audit_status) === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
}
return '开方'
}
const handlePrescription = (row: any) => {
if (!row?.id) {
feedback.msgWarning('诊单信息不完整')
return
}
prescriptionRef.value?.open({
...row,
diagnosis_id: row.id,
id: row.appointment_id || 0,
})
}
const onPrescriptionSuccess = () => {
getLists()
;(editRef.value as { refreshCaseList?: () => void } | undefined)?.refreshCaseList?.()
}
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 'cancelAssign': handleCancelAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
handleVideoQRCode(row)
break
case 'confirmQr': handleMiniProgramQRCode(row); break
case 'cancelApt': handleCancelAppointment(row); break
case 'guahaoLogs': openGuahaoLogDrawer(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))
}
/** 单条挂号记录是否允许取消(已预约 1 / 已过号 4) */
const canCancelAppointmentItem = (apt: any) => {
const s = Number(apt?.status)
return !!(apt?.id && (s === 1 || s === 4))
}
/**
* 「更多」里行级取消:仅当列表只展示一条挂号时可点,避免多条时与主键 appointment_id 不对齐
* 多条时在「挂号」列每条旁单独取消
*/
const canCancelAppointmentFromDropdown = (row: any) => {
if (!canCancelAppointmentRow(row)) return false
const rows = appointmentRows(row)
return rows.length <= 1
}
// 取消挂号(行级,唯一一条时)
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
}
const rows = appointmentRows(row)
const target = rows.length === 1 ? rows[0] : null
const aptId = target?.id && canCancelAppointmentItem(target) ? target.id : row.appointment_id
try {
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
await cancelAppointment({ id: aptId })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
await fetchGuahaoLogs(row.id)
}
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
/** 取消指定挂号(多条展示时用) */
const handleCancelAppointmentItem = async (row: any, apt: any) => {
if (!canCancelAppointmentItem(apt)) {
const s = Number(apt?.status)
if (s === 3) {
feedback.msgWarning('已完成就诊,无法取消挂号')
} else {
feedback.msgWarning('该挂号无法取消')
}
return
}
const doc = apt.doctor_name || '—'
const t = apt.time_text || '—'
try {
await feedback.confirm(`确定取消「${doc}${t} 的挂号吗?(患者:${row.patient_name || '—'}`)
await cancelAppointment({ id: apt.id })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
await fetchGuahaoLogs(row.id)
}
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
const guahaoLogDrawerVisible = ref(false)
const guahaoLogLoading = ref(false)
const guahaoLogRows = ref<any[]>([])
const guahaoLogContextRow = ref<any>(null)
function resetGuahaoLogDrawer() {
guahaoLogRows.value = []
guahaoLogContextRow.value = null
}
function formatGuahaoLogOperator(log: any) {
const name = String(log?.admin_name || '').trim()
const aid = Number(log?.admin_id)
if (name) return name
if (aid > 0) return `管理员 #${aid}`
return '—'
}
async function fetchGuahaoLogs(diagnosisId: number) {
guahaoLogLoading.value = true
try {
const res: any = await tcmDiagnosisGuahaoLogList({ id: diagnosisId })
guahaoLogRows.value = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []
} catch {
guahaoLogRows.value = []
} finally {
guahaoLogLoading.value = false
}
}
async function openGuahaoLogDrawer(row: any) {
if (!row?.id) {
feedback.msgWarning('诊单信息不完整')
return
}
guahaoLogContextRow.value = row
guahaoLogDrawerVisible.value = true
await fetchGuahaoLogs(Number(row.id))
}
/** 预约抽屉保存成功后:静默刷新列表 + 顶部计数;若挂号日志抽屉打开则同步日志 */
async function handleAppointmentSaved() {
await getLists({ silent: true })
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id) {
await fetchGuahaoLogs(Number(guahaoLogContextRow.value.id))
}
}
// 患者挂号
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()
const router = useRouter()
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
const goReadonly = (row: any) => {
// 没分配 readonlyDetail 权限的管理员:双击行也不跳转(按钮已用 v-perms 隐藏)
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
const id = Number(row?.id)
if (!id || id <= 0) return
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
}
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;
flex-wrap: wrap;
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);
}
}
.pending-assign-wrap {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-left: 8px;
.chip-pending {
margin-left: 0;
}
}
.pending-assign-month {
width: 128px;
}
.pending-assign-search {
width: 220px;
max-width: 42vw;
}
}
.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-actions {
margin-top: 4px;
line-height: 1.2;
}
&.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);
}
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
.unserved-days {
display: inline-block;
min-width: 28px;
font-weight: 600;
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: default;
}
.unserved-muted {
color: var(--el-text-color-placeholder);
font-weight: 400;
}
.unserved-green {
color: #16a34a;
}
.unserved-orange {
color: #ea580c;
}
.unserved-red {
color: #dc2626;
}
.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>