This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
@@ -30,8 +30,8 @@
</el-radio-group>
</el-form-item>
<!-- 预约类型 -->
<el-form-item label="预约类型:">
<!-- 挂号类型 -->
<el-form-item label="挂号类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<el-radio value="text">图文问诊</el-radio>
@@ -2,29 +2,47 @@
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示单聊记录:已合并患者 与
展示患者所有诊单中与
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。
打开页面后自动加载并同步最新消息,页面可见期间每 30 秒继续检查更新。
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新(后台异步)
同步最新
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<el-icon class="mr-1"><Refresh /></el-icon>
重新加载已归档
</el-button>
</div>
<div v-loading="loading" class="chat-wrap">
<template v-if="!loading && rows.length">
</div>
<p v-if="syncing" class="sync-status" role="status" aria-live="polite">
<template v-if="history.phase === 'checking_accounts'">
正在核验聊天账号<span v-if="history.candidateAccounts">,{{ history.checkedAccounts }} / {{ history.candidateAccounts }}</span>
</template>
<template v-else>正在同步聊天记录<span v-if="history.totalPeers">,已处理 {{ history.processedPeers }} / {{ history.totalPeers }} 个会话</span>,新增 {{ history.inserted }} 条</template>
</p>
<p v-else-if="history.lastSyncAt" class="sync-status">上次同步:{{ formatTime(history.lastSyncAt) }}</p>
<p v-if="history.skippedAccounts" class="sync-status">已跳过 {{ history.skippedAccounts }} 个未注册或已失效的聊天账号,已有归档不受影响。</p>
<el-alert v-if="history.readError" type="error" show-icon :closable="false" class="mb-3" :title="`读取聊天记录失败:${history.readError}`" />
<el-alert v-if="history.syncError" type="error" show-icon :closable="false" class="mb-3" :title="`同步失败:${history.syncError}`" />
<el-alert v-if="history.partialErrors.length" type="warning" show-icon :closable="false" class="mb-3" title="聊天记录未完全同步">
<p v-for="error in history.partialErrors.slice(0, 3)" :key="error" class="panel-tip">{{ error }}</p>
<details v-if="history.partialErrors.length > 3">
<summary>查看其余 {{ history.partialErrors.length - 3 }} 项同步问题</summary>
<p v-for="error in history.partialErrors.slice(3)" :key="error" class="panel-tip">{{ error }}</p>
</details>
</el-alert>
<div v-loading="loading && !rows.length" class="chat-wrap">
<template v-if="rows.length">
<div class="chat-list">
<div
v-for="item in rows"
:key="item.raw.msg_id"
:key="`${item.raw.from_account || ''}-${item.raw.to_account || ''}-${item.raw.msg_id}`"
class="chat-row"
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
@@ -64,31 +82,40 @@
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
<el-empty v-else-if="!loading" :description="emptyDescription" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import { watch, computed, toRefs, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import dayjs from 'dayjs'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
import { createImChatHistory } from '@/utils/im-chat-history'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
const documentVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
const controller = createImChatHistory({
load: (diagnosisId) => getImChatMessages({ diagnosis_id: diagnosisId, only_archived: 1 }),
sync: (diagnosisId, syncToken) => triggerImChatSync({ diagnosis_id: diagnosisId, ...(syncToken ? { sync_token: syncToken } : {}) }),
notify: (kind, message) => ElMessage[kind](message),
visible: documentVisible()
})
const history = controller.state
const { loading, syncing, rows: rawRows, patientName } = toRefs(history)
const emptyDescription = computed(() => {
if (history.readError) return '聊天记录读取失败,请重试'
if (history.syncError || history.partialErrors.length) return '暂未读取到聊天记录,请重试同步'
if (syncing.value) return '正在同步聊天记录…'
return history.hasLoaded ? '暂无聊天记录' : '正在加载聊天记录…'
})
interface EnrichedRow {
raw: any
@@ -109,16 +136,21 @@ const MSG_TYPE_LABELS: Record<string, string> = {
}
const rows = computed<EnrichedRow[]>(() =>
rawRows.value.map((row) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
rawRows.value.flatMap((row) => {
const messages = row.msg_type === 'composite' && Array.isArray(row.parts)
? row.parts.map((part: any, index: number) => ({ ...row, ...part, msg_id: `${row.msg_id}:${index}` }))
: [row]
return messages.map((row: any) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
})
)
function formatTime(ts: number | undefined) {
@@ -145,54 +177,37 @@ function senderLabel(row: any) {
return patientName.value ? `患者(${patientName.value})` : '患者'
}
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({
diagnosis_id: props.diagnosisId,
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rawRows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rawRows.value = []
} finally {
loading.value = false
}
}
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
function reloadArchived() {
return controller.reload()
}
function triggerSync() {
return controller.sync()
}
watch(
() => props.diagnosisId,
() => {
load()
},
(diagnosisId) => controller.setDiagnosis(diagnosisId),
{ immediate: true }
)
)
let activated = true
function updateVisibility() {
controller.setVisible(activated && documentVisible())
}
onMounted(() => document.addEventListener('visibilitychange', updateVisibility))
onActivated(() => {
activated = true
updateVisibility()
})
onDeactivated(() => {
activated = false
updateVisibility()
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', updateVisibility)
controller.dispose()
})
defineExpose({ refresh: reloadArchived })
</script>
@@ -209,9 +224,15 @@ defineExpose({ refresh: reloadArchived })
font-size: 12px;
}
}
.toolbar {
.toolbar {
display: flex;
align-items: center;
align-items: center;
flex-wrap: wrap;
}
.sync-status {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-wrap {
min-height: 120px;
+43 -19
View File
@@ -259,7 +259,20 @@
</div>
</template>
</el-table-column>
<el-table-column label="确认" width="88" align="center">
<el-table-column label="挂号类型" min-width="120">
<template #default="{ row }">
<template v-if="appointmentRows(row).length">
<div v-for="apt in appointmentRows(row)" :key="apt.id || `${apt.doctor_id}-${apt.time_text}`" class="mb-2">
<div>{{ appointmentTypeDescription(apt.appointment_type) }}</div>
<div v-if="appointmentRows(row).length > 1" class="text-xs text-gray-500">
{{ apt.doctor_name || '—' }} · {{ apt.time_text || '—' }}
</div>
</div>
</template>
<span v-else class="apt-none">—</span>
</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>
@@ -388,7 +401,7 @@
<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="hasPermission(['tcm.diagnosis/guahao']) && hasActiveAppointment(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt"
@@ -731,7 +744,8 @@ 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 { hasPermission } from '@/utils/perm'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
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'
@@ -1377,10 +1391,11 @@ type VideoCallHint = {
end_time?: number
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const canWatchCallEntry = (row: any) =>
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallLiveTooltip = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined
@@ -1404,7 +1419,11 @@ const watchCallEnterTooltip = (row: any) => {
return watchCallLiveTooltip(row)
}
const onWatchCallEntryClick = (row: any) => {
const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
return
@@ -1716,7 +1735,7 @@ const handleRowAction = (cmd: string, row: any) => {
case 'cancelAssign': handleCancelAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
handleVideoQRCode(row)
@@ -1753,7 +1772,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}]
}
@@ -1779,12 +1799,14 @@ const appointmentCellClasses = (row: any) => {
* 找到当前可用的已预约挂号。
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
*/
const activeAppointment = (row: any) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
const activeAppointmentRow = (row: any) => {
const apt = activeAppointment(row)
const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row, videoOnly)
if (!apt) return null
return {
@@ -1794,12 +1816,14 @@ const activeAppointmentRow = (row: any) => {
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text
appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
}
}
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
/** 诊单二维码适用于所有已预约挂号;视频入口仅适用于视频问诊。 */
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => {
@@ -1983,11 +2007,11 @@ const submitFillIdCard = async () => {
}
}
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
// 生成视频二维码(跳转登录页)- 仅已预约的视频问诊可生成
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
if (!qrcodeRow.patient_id) {
+43 -23
View File
@@ -155,13 +155,15 @@
>
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型:{{ appointmentTypeDescription(apt.appointment_type) }}</span>
</div>
</template>
<template v-else>
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型:{{ appointmentTypeDescription(row.appointment_type) }}</span>
</template>
</template>
<span v-else class="dh5-apt-none">未挂号</span>
@@ -299,7 +301,7 @@
<span>视频二维码</span>
</button>
<button
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
v-if="hasActiveAppointment(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
class="dh5-action"
@click="runAction('confirmQr')"
>
@@ -584,7 +586,8 @@
</div>
</template>
<script setup lang="ts" name="tcmDiagnosisH5">
<script setup lang="ts" name="tcmDiagnosisH5">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import {
tcmDiagnosisLists,
tcmDiagnosisDelete,
@@ -924,7 +927,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}]
}
@@ -949,11 +953,13 @@ const appointmentRowClass = (row: any) => {
/**
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
*/
const activeAppointment = (row: any) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
const activeAppointmentRow = (row: any) => {
const apt = activeAppointment(row)
const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row, videoOnly)
if (!apt) return null
return {
@@ -963,11 +969,13 @@ const activeAppointmentRow = (row: any) => {
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text
appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
}
}
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
@@ -981,9 +989,10 @@ const isAssignedAssistant = (row: any) => {
if (aid === null || aid === undefined || aid === '') return false
return Number(aid) === Number(userStore.userInfo?.id)
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const canWatchCallEntry = (row: any) =>
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallShowEnterButton = (row: any) => {
const st = watchCallState(row)
return st === 'live' || st === 'pending_room'
@@ -1015,7 +1024,11 @@ const watchCallVisibleForRow = (row: any) => {
}
const watchCallDialogVisible = ref(false)
const watchCallDiagnosisId = ref(0)
const onWatchCallEntryClick = (row: any) => {
const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
return
@@ -1087,7 +1100,7 @@ const runAction = (cmd: string) => {
case 'assign': handleSingleAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
handleVideoQRCode(row); break
@@ -1262,9 +1275,9 @@ const qrcodeAppointmentTimeText = computed(() => {
})
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
@@ -1805,8 +1818,9 @@ $dh5-card-bg: #ffffff;
}
}
.dh5-apt-item {
display: flex;
.dh5-apt-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 4px 8px;
@@ -1830,11 +1844,17 @@ $dh5-card-bg: #ffffff;
font-weight: 500;
}
.dh5-apt-time {
.dh5-apt-time {
color: $dh5-text-mute;
font-size: 12px;
margin-left: auto;
}
margin-left: auto;
}
.dh5-apt-type {
flex-basis: 100%;
color: $dh5-text-mute;
font-size: 12px;
}
.dh5-apt-none {
color: $dh5-warn;