Files
zyt/admin/src/views/tcm/diagnosis/index.vue
T
2026-03-20 13:56:40 +08:00

1499 lines
52 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.appointment_date === tab.value }]"
@click="handleDateTabClick(tab.value)"
>
{{ tab.label }}
<em v-if="tab.value && dateCounts[tab.value] !== undefined">{{ dateCounts[tab.value] }}</em>
</span>
</div>
<div class="search-row">
<el-input v-model="formData.patient_name" 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="患者" min-width="150">
<template #default="{ row }">
<div class="patient-cell">
<div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div>
<div class="patient-info">
<div class="patient-name">{{ row.patient_name }}</div>
<div class="patient-meta">{{ row.id }} · {{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="挂号" min-width="180">
<template #default="{ row }">
<div class="appointment-cell" :class="{ 'has-apt': row.has_appointment }">
<template v-if="row.has_appointment">
<div class="apt-badge">已挂号</div>
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
</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="130">
<template #default="{ row }">
<div class="diagnosis-cell">
<span class="diagnosis-date">{{ row.diagnosis_date_text || '-' }}</span>
<template v-if="getDictLabel(diagnosisTypeOptions, row.diagnosis_type) || getDictLabel(syndromeTypeOptions, row.syndrome_type)">
<span class="diagnosis-extra">{{ [getDictLabel(diagnosisTypeOptions, row.diagnosis_type), getDictLabel(syndromeTypeOptions, row.syndrome_type)].filter(Boolean).join(' · ') }}</span>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="助理" width="72" 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="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="row.has_appointment && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao'])" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="row.has_appointment && 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="getLists" />
<detail-popup ref="detailRef" />
<appointment-popup ref="appointmentRef" @success="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">请使用微信扫描二维码</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 }} ({{ 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="手机号">{{ 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, RefreshRight, Picture, User, Document, Delete, CircleClose } from '@element-plus/icons-vue'
import EditPopup from './edit.vue'
import DetailPopup from './detail.vue'
import AppointmentPopup from './appointment.vue'
import useUserStore from '@/stores/modules/user'
import dayjs from 'dayjs'
// 格式化时间戳为日期时间
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 formData = reactive({
patient_name: '',
diagnosis_type: '',
syndrome_type: '',
assistant_id: '',
start_time: '',
end_time: '',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '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'
pager.page = 1
getLists()
fetchDateCounts()
}
const setConfirmed = (v: string) => {
formData.diagnosis_confirmed = v as '' | '0' | '1'
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 handleDateTabClick = async (value: string) => {
formData.appointment_date = value
pager.page = 1
await getLists()
fetchDateCounts()
}
// 获取各日期挂号数量
const fetchDateCounts = async () => {
try {
const [today, tomorrow, dayAfter] = 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 })
])
dateCounts.value = {
[todayStr.value]: today?.count ?? 0,
[tomorrowStr.value]: tomorrow?.count ?? 0,
[dayAfterStr.value]: dayAfter?.count ?? 0
}
} catch (e) {
console.error('获取日期数量失败', e)
}
}
// 日期快捷选项(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 = ''
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
})
// 重置
const handleReset = () => {
activeTab.value = 'all'
formData.patient_name = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_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()
const userStore = useUserStore()
// 创建订单相关
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: ''
})
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 = ''
orderFormRef.value?.clearValidate()
createOrderVisible.value = true
}
const resetOrderForm = () => {
orderForm.patient_id = ''
orderForm.patient_name = ''
orderForm.phone = ''
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
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 }
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 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 (!row.has_appointment) {
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
}
}
// 取消挂号
const handleCancelAppointment = async (row: any) => {
if (!row.has_appointment || !row.appointment_id) {
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
}
}
// 生成视频二维码(跳转登录页)- 仅已挂号患者可生成
const handleVideoQRCode = async (row: any) => {
if (!row.has_appointment) {
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 (!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 || '删除失败')
}
}
onMounted(async () => {
await getDictOptions()
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
getLists()
fetchDateCounts()
})
</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);
}
}
}
.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;
}
}
.appointment-cell {
padding: 4px 8px;
border-radius: 6px;
min-height: 36px;
&.has-apt {
background: linear-gradient(135deg, rgba(var(--el-color-success-rgb), 0.12), rgba(var(--el-color-success-rgb), 0.06));
border: 1px solid rgba(var(--el-color-success-rgb), 0.3);
.apt-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--el-color-success-dark-2);
background: var(--el-color-success-light-5);
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 2px;
}
.apt-doctor {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.apt-time {
font-size: 12px;
color: var(--el-color-success);
margin-top: 2px;
}
}
.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: var(--el-color-success);
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);
}
}
.assistant-cell {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.action-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.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>