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
+120 -10
View File
@@ -55,18 +55,19 @@
<ImagePicker />
<FilePicker />
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口避免初始化登录未完成错误 -->
<AudioCallPicker v-if="isCallReady" />
<AudioCallPicker v-if="isCallReady && canAudioCall" />
<!-- 视频接通后 doBindCallRoom 后端 bindCallRoom 会调 CreateCloudRecordingRecordParams.RecordMode=2混流/合流 TrtcCloudRecordingService -->
<VideoCallPicker v-if="isCallReady" />
<VideoCallPicker v-if="isCallReady && canVideoCall" />
<!-- 群组视频通话基于 TUICallKitServer.calls多人通话入口 -->
<el-button
v-if="isCallReady"
v-if="isCallReady && canVideoCall"
size="small"
type="primary"
@click="startGroupVideoCall"
>
群视频
</el-button>
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊不支持音视频通话</span>
</div>
</template>
</MessageInput>
@@ -138,12 +139,15 @@ import {
bindCallRoom,
endCall,
getCallSignature,
triggerImChatSync,
startCall
} from '@/api/tcm'
import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { createImChatArchiveTrigger } from '@/utils/im-chat-archive-trigger'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import { appointmentTypeDescription } from '@/utils/appointment-type'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
@@ -185,7 +189,17 @@ const isReady = ref(false)
const error = ref('')
const patientName = ref('')
const appointmentType = ref<string | null | undefined>('video')
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
const appointmentId = ref(0)
const confirmedTypeLabel = ref('')
const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointmentTypeDescription(appointmentType.value))
const serverAllowsVideo = ref(false)
const serverAllowsAudio = ref(false)
const callDisabledReason = ref('正在确认挂号类型')
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
let releaseAppointmentCallGuard: (() => void) | undefined
let chatContextVersion = 0
let outgoingCallType = 2
const patientId = ref<number | null>(null)
const diagnosisId = ref<number | null>(null)
const loadingText = ref('正在初始化...')
@@ -197,6 +211,68 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore()
const syncChatArchive = createImChatArchiveTrigger(triggerImChatSync, () => {
console.warn('[chat-dialog] 聊天记录归档未完成,可在诊单聊天记录中重试同步')
})
let chatArchiveTimer: ReturnType<typeof setInterval> | undefined
let chatArchiveDebounce: ReturnType<typeof setTimeout> | undefined
function queueChatArchive() {
const id = diagnosisId.value
if (!visible.value || !isReady.value || !id) return
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveDebounce = setTimeout(() => {
chatArchiveDebounce = undefined
void syncChatArchive(id)
}, 500)
}
function stopChatArchiveWatch(flush = false) {
if (chatArchiveTimer) clearInterval(chatArchiveTimer)
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveTimer = undefined
chatArchiveDebounce = undefined
if (flush && diagnosisId.value && isReady.value) void syncChatArchive(diagnosisId.value)
TUIChatEngine?.chat?.off?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
}
function startChatArchiveWatch() {
stopChatArchiveWatch()
TUIChatEngine?.chat?.on?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
chatArchiveTimer = setInterval(queueChatArchive, 15000)
queueChatArchive()
}
function syncAppointmentCallPolicy(res: {
appointment_type?: string | null; appointment_type_desc?: string;
appointment_id?: number; can_video_call?: boolean; can_audio_call?: boolean; call_disabled_reason?: string
}) {
if ('appointment_type' in res) appointmentType.value = res.appointment_type
confirmedTypeLabel.value = res.appointment_type_desc || ''
serverAllowsVideo.value = res.can_video_call === true
serverAllowsAudio.value = res.can_audio_call === true
callDisabledReason.value = res.call_disabled_reason || '当前挂号不支持该通话方式'
if (Number(res.appointment_id) > 0) appointmentId.value = Number(res.appointment_id)
}
async function prepareAppointmentCall(callType: number) {
const contextVersion = chatContextVersion
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
if (!visible.value || !allowed()) {
const message = appointmentType.value === 'text' ? '图文问诊不支持音视频通话' : callDisabledReason.value
feedback.msgWarning(message)
throw new Error(message)
}
// 发起前重新读取服务端挂号,防止打开聊天后问诊类型已被修改。
const res = await getCallSignature({
patient_id: patientId.value, diagnosis_id: diagnosisId.value, appointment_id: appointmentId.value
})
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
syncAppointmentCallPolicy(res)
if (!allowed()) {
feedback.msgWarning(callDisabledReason.value)
throw new Error(callDisabledReason.value)
}
outgoingCallType = callType
}
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling'
@@ -628,6 +704,7 @@ function installTUICallKitRoomHooks() {
const original = server[methodName]
if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) {
await checkAppointmentOutgoingCall(args[0])
const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName)
return result
@@ -695,7 +772,8 @@ async function ensureCallRecordStarted() {
await startCall({
diagnosis_id: diagnosisId.value,
patient_id: patientId.value,
call_type: 2
appointment_id: appointmentId.value,
call_type: outgoingCallType
})
} catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e)
@@ -939,6 +1017,9 @@ onMounted(() => {
})
onUnmounted(() => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
clearLocalRecordingStartTimer()
localCallRecorder.reset()
tearDownCallRoomBinding?.()
@@ -1001,7 +1082,8 @@ watch(() => activeConversation.value, async (newConversation) => {
try {
const res = await getCallSignature({
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
diagnosis_id: diagnosisId.value
diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
})
syncLochostVodFromSignature(res)
patientUserId.value = res.patientUserId
@@ -1016,7 +1098,11 @@ watch(() => activeConversation.value, async (newConversation) => {
}
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
stopChatArchiveWatch(true)
const contextVersion = ++chatContextVersion
releaseAppointmentCallGuard?.()
releaseAppointmentCallGuard = registerAppointmentCallGuard(prepareAppointmentCall)
visible.value = true
isMinimized.value = false
posX.value = 100
@@ -1024,6 +1110,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
resetCallKitPositionToLeft()
patientName.value = data.patientName
appointmentType.value = data.appointmentType
appointmentId.value = Number(data.appointmentId || 0)
confirmedTypeLabel.value = ''
serverAllowsVideo.value = false
serverAllowsAudio.value = false
callDisabledReason.value = '正在确认挂号类型'
patientId.value = data.patientId
diagnosisId.value = data.diagnosisId || null
error.value = ''
@@ -1036,8 +1127,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 获取医生的签名信息
const res = await getCallSignature({
patient_id: data.patientId,
diagnosis_id: data.diagnosisId || 0
diagnosis_id: data.diagnosisId || 0,
appointment_id: appointmentId.value
})
if (contextVersion !== chatContextVersion || !visible.value) return
syncAppointmentCallPolicy(res)
syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id
console.log('后端返回的签名数据:', res)
@@ -1123,7 +1217,9 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 等待 UIKit 初始化完成后创建并激活会话
setTimeout(async () => {
if (contextVersion !== chatContextVersion || !visible.value) return
isReady.value = true
startChatArchiveWatch()
// 使用 nextTick 确保组件已渲染
await nextTick()
@@ -1164,6 +1260,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
const startGroupVideoCall = async () => {
if (!canVideoCall.value) {
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
return
}
if (!isCallReady.value) {
feedback.msgWarning('通话服务初始化中,请稍后再试')
return
@@ -1180,7 +1280,8 @@ const startGroupVideoCall = async () => {
console.warn('assistant_id 为空,尝试重新获取')
const res = await getCallSignature({
patient_id: patientId.value!,
diagnosis_id: diagnosisId.value
diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
})
syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id
@@ -1360,6 +1461,9 @@ const onHeaderMouseUp = () => {
}
const handleClose = async () => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
unbindImHangupMessageListener()
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
if (isCallReady.value) {
@@ -1529,6 +1633,12 @@ defineExpose({ open })
}
/* 消息工具栏样式 */
.text-consultation-hint {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.message-toolbar {
display: flex;
gap: 8px;