更新
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+115
-115
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-DwSVWep6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-qOBmgxQV.css">
|
||||
<script type="module" crossorigin src="./assets/index-R5GzqA8s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CED5X2W4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -23,6 +23,7 @@ const props = defineProps<{
|
||||
chatBusy: Readonly<Ref<boolean>>
|
||||
notice: Readonly<Ref<string>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
onSendText: (text: string) => Promise<void>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onLoadMore: () => Promise<void>
|
||||
@@ -42,6 +43,15 @@ const isChat = computed(() => props.mode.value === 'chat')
|
||||
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
||||
const videoVisible = computed(() => !isChat.value || isCalling.value)
|
||||
const canCapture = computed(() => props.phase.value === 'connected')
|
||||
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
|
||||
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
|
||||
const transcriptionStatusText = computed(() => {
|
||||
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
|
||||
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
|
||||
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
|
||||
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
|
||||
return '自动录音已结束'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.messages.value.length,
|
||||
@@ -242,6 +252,19 @@ async function captureScreenshot(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<div v-if="isCalling" class="video-actions">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
class="recording-status"
|
||||
:class="{
|
||||
'recording-status--active': transcriptionActive,
|
||||
'recording-status--error': transcriptionFailed,
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="recording-indicator" aria-hidden="true" />
|
||||
{{ transcriptionStatusText }}
|
||||
</div>
|
||||
<button
|
||||
class="capture-button"
|
||||
type="button"
|
||||
|
||||
Vendored
+7
@@ -30,6 +30,13 @@ interface DoctorConsultationApi {
|
||||
hangup(): Promise<void>
|
||||
hostCallReady(ok: boolean, message?: string): void
|
||||
screenshotResult(ok: boolean, message: string): void
|
||||
transcriptionResult(
|
||||
operation: 'start' | 'segment' | 'stop',
|
||||
sessionId: string,
|
||||
segmentId: string,
|
||||
ok: boolean,
|
||||
message: string,
|
||||
): void
|
||||
}
|
||||
|
||||
interface QtVideoBridge {
|
||||
|
||||
+493
-17
@@ -15,6 +15,7 @@ import './style.css'
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
type CompanionMode = 'chat' | 'video'
|
||||
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error'
|
||||
|
||||
interface NormalizedCallConfig {
|
||||
SDKAppID: number
|
||||
@@ -38,11 +39,74 @@ interface UiChatMessage {
|
||||
|
||||
interface BridgeMessage {
|
||||
source: 'doctor-call'
|
||||
event: 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error'
|
||||
event:
|
||||
| 'ready'
|
||||
| 'call-start-request'
|
||||
| 'status'
|
||||
| 'room'
|
||||
| 'hangup'
|
||||
| 'error'
|
||||
| 'transcription-start-request'
|
||||
| 'transcription-segment'
|
||||
| 'transcription-stop'
|
||||
diagnosisId?: number | string
|
||||
status?: string
|
||||
roomId?: string
|
||||
message?: string
|
||||
sessionId?: string
|
||||
language?: string
|
||||
segment?: {
|
||||
segment_id: string
|
||||
speaker_user_id: string
|
||||
speaker_role: 'doctor' | 'patient' | 'unknown'
|
||||
timestamp: number
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
interface RealtimeTranscriberMessage {
|
||||
segmentId: string
|
||||
speakerUserId: string
|
||||
sourceText: string
|
||||
timestamp: number
|
||||
isCompleted: boolean
|
||||
}
|
||||
|
||||
interface RealtimeTranscriberListener {
|
||||
onReceiveTranscriberMessage: (
|
||||
roomId: string | number,
|
||||
message: RealtimeTranscriberMessage,
|
||||
) => void
|
||||
onRealtimeTranscriberStarted: (
|
||||
roomId: string | number,
|
||||
robotId: string,
|
||||
sourceLanguage: string,
|
||||
) => void
|
||||
onRealtimeTranscriberStopped: (roomId: string | number, robotId: string) => void
|
||||
onRealtimeTranscriberError: (
|
||||
roomId: string | number,
|
||||
robotId: string,
|
||||
error: number,
|
||||
errorMessage: string,
|
||||
) => void
|
||||
}
|
||||
|
||||
interface RealtimeTranscriberManager {
|
||||
addListener(listener: RealtimeTranscriberListener): void
|
||||
removeListener(listener: RealtimeTranscriberListener): void
|
||||
startRealtimeTranscriber(config: { sourceLanguage: string }): Promise<string>
|
||||
stopRealtimeTranscriber(robotId: string): Promise<void>
|
||||
}
|
||||
|
||||
interface PendingTranscriptionReply {
|
||||
sessionId: string
|
||||
resolve: (value: boolean) => void
|
||||
promise: Promise<boolean>
|
||||
}
|
||||
|
||||
interface PendingSegment {
|
||||
message: BridgeMessage
|
||||
attempts: number
|
||||
}
|
||||
|
||||
const phase = ref<CallPhase>('ready')
|
||||
@@ -54,6 +118,7 @@ const chatReady = ref(false)
|
||||
const chatBusy = ref(false)
|
||||
const notice = ref('')
|
||||
const hasMoreMessages = ref(false)
|
||||
const transcriptionState = ref<TranscriptionState>('idle')
|
||||
|
||||
let activeConfig: NormalizedCallConfig | null = null
|
||||
let chat: any = null
|
||||
@@ -65,6 +130,23 @@ let endNotified = true
|
||||
let starting = false
|
||||
let emittedRoomId = ''
|
||||
let resolveHostCallReady: ((value: boolean) => void) | null = null
|
||||
const pendingTranscriptionStarts = new Map<string, PendingTranscriptionReply>()
|
||||
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
|
||||
let transcriptionSessionId = ''
|
||||
let transcriptionGeneration = 0
|
||||
let transcriberRunning = false
|
||||
let transcriberManager: RealtimeTranscriberManager | null = null
|
||||
let transcriberListener: RealtimeTranscriberListener | null = null
|
||||
let transcriberRobotId = ''
|
||||
let transcriptionStartPromise: Promise<void> | null = null
|
||||
let transcriptionStopPromise: Promise<void> | null = null
|
||||
let hangupNotification: Promise<void> | null = null
|
||||
let callCycleGeneration = 0
|
||||
let autoTranscriptionAttemptedGeneration = -1
|
||||
let lastTranscriberMessageAt = 0
|
||||
let transcriberStoppedAt = 0
|
||||
const acknowledgedSegmentIds = new Set<string>()
|
||||
const pendingSegments = new Map<string, PendingSegment>()
|
||||
|
||||
function initializeQtWebChannel(): void {
|
||||
const transport = window.qt?.webChannelTransport
|
||||
@@ -457,17 +539,372 @@ async function sendAttachment(file: File): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyHangup(status = 'ended'): void {
|
||||
if (endNotified) return
|
||||
function newTranscriptionSessionId(): string {
|
||||
const random = window.crypto?.randomUUID?.()
|
||||
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function requestTranscriptionStart(sessionId: string): Promise<boolean> {
|
||||
if (!activeConfig || !window.qtVideoBridge?.notify) return Promise.resolve(false)
|
||||
const existing = pendingTranscriptionStarts.get(sessionId)
|
||||
if (existing) return existing.promise
|
||||
const pending = {} as PendingTranscriptionReply
|
||||
pending.sessionId = sessionId
|
||||
pending.promise = new Promise<boolean>((resolve) => {
|
||||
pending.resolve = resolve
|
||||
})
|
||||
pendingTranscriptionStarts.set(sessionId, pending)
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'transcription-start-request',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
sessionId,
|
||||
language: 'zh',
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
if (pendingTranscriptionStarts.get(sessionId) !== pending) return
|
||||
pendingTranscriptionStarts.delete(sessionId)
|
||||
pending.resolve(false)
|
||||
}, 15000)
|
||||
return pending.promise
|
||||
}
|
||||
|
||||
function requestTranscriptionStop(
|
||||
sessionId: string,
|
||||
status: 'completed' | 'partial' | 'failed',
|
||||
): Promise<boolean> {
|
||||
if (!activeConfig || !sessionId || !window.qtVideoBridge?.notify) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const existing = pendingTranscriptionStops.get(sessionId)
|
||||
if (existing) return existing.promise
|
||||
const pending = {} as PendingTranscriptionReply
|
||||
pending.sessionId = sessionId
|
||||
pending.promise = new Promise<boolean>((resolve) => {
|
||||
pending.resolve = resolve
|
||||
})
|
||||
pendingTranscriptionStops.set(sessionId, pending)
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'transcription-stop',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
sessionId,
|
||||
status,
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
if (pendingTranscriptionStops.get(sessionId) !== pending) return
|
||||
pendingTranscriptionStops.delete(sessionId)
|
||||
pending.resolve(false)
|
||||
}, 15000)
|
||||
return pending.promise
|
||||
}
|
||||
|
||||
function getTranscriberManager(): RealtimeTranscriberManager {
|
||||
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
|
||||
const cloud = engine?.getTRTCCloudInstance?.()
|
||||
const manager = cloud?.getAITranscriberManager?.() as Partial<RealtimeTranscriberManager> | null
|
||||
if (
|
||||
!manager
|
||||
|| typeof manager.addListener !== 'function'
|
||||
|| typeof manager.removeListener !== 'function'
|
||||
|| typeof manager.startRealtimeTranscriber !== 'function'
|
||||
|| typeof manager.stopRealtimeTranscriber !== 'function'
|
||||
) {
|
||||
throw new Error('当前视频服务未开通实时语音转写')
|
||||
}
|
||||
return manager as RealtimeTranscriberManager
|
||||
}
|
||||
|
||||
function handleTranscriberMessage(
|
||||
_roomId: string | number,
|
||||
message: RealtimeTranscriberMessage,
|
||||
): void {
|
||||
if (
|
||||
!activeConfig
|
||||
|| !['recording', 'stopping'].includes(transcriptionState.value)
|
||||
|| !transcriptionSessionId
|
||||
|| message.isCompleted !== true
|
||||
) return
|
||||
lastTranscriberMessageAt = Date.now()
|
||||
const segmentId = String(message.segmentId ?? '').trim()
|
||||
const text = String(message.sourceText ?? '').trim()
|
||||
if (
|
||||
!segmentId
|
||||
|| acknowledgedSegmentIds.has(segmentId)
|
||||
|| pendingSegments.has(segmentId)
|
||||
|| !text
|
||||
) return
|
||||
const speakerUserId = String(message.speakerUserId ?? '').trim()
|
||||
const bridgeMessage: BridgeMessage = {
|
||||
source: 'doctor-call',
|
||||
event: 'transcription-segment',
|
||||
diagnosisId: activeConfig.diagnosisId,
|
||||
sessionId: transcriptionSessionId,
|
||||
segment: {
|
||||
segment_id: segmentId.slice(0, 160),
|
||||
speaker_user_id: speakerUserId.slice(0, 160),
|
||||
speaker_role: speakerUserId === activeConfig.userID
|
||||
? 'doctor'
|
||||
: speakerUserId === activeConfig.targetUserId
|
||||
? 'patient'
|
||||
: 'unknown',
|
||||
timestamp: Math.max(0, Math.trunc(Number(message.timestamp) || 0)),
|
||||
text: text.slice(0, 4000),
|
||||
},
|
||||
}
|
||||
pendingSegments.set(segmentId, { message: bridgeMessage, attempts: 1 })
|
||||
emit(bridgeMessage)
|
||||
}
|
||||
|
||||
function subscribeTranscriber(): {
|
||||
manager: RealtimeTranscriberManager
|
||||
listener: RealtimeTranscriberListener
|
||||
} {
|
||||
if (transcriberManager && transcriberListener) {
|
||||
return { manager: transcriberManager, listener: transcriberListener }
|
||||
}
|
||||
const manager = getTranscriberManager()
|
||||
const listener: RealtimeTranscriberListener = {
|
||||
onReceiveTranscriberMessage: handleTranscriberMessage,
|
||||
onRealtimeTranscriberStarted: () => undefined,
|
||||
onRealtimeTranscriberStopped: (_roomId, robotId) => {
|
||||
if (robotId !== transcriberRobotId) return
|
||||
transcriberRunning = false
|
||||
transcriberStoppedAt = Date.now()
|
||||
if (transcriptionState.value === 'recording') {
|
||||
void stopTranscription('partial', true)
|
||||
}
|
||||
},
|
||||
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
|
||||
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
|
||||
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
|
||||
void stopTranscription('partial', true)
|
||||
},
|
||||
}
|
||||
manager.addListener(listener)
|
||||
transcriberManager = manager
|
||||
transcriberListener = listener
|
||||
return { manager, listener }
|
||||
}
|
||||
|
||||
function unsubscribeTranscriber(
|
||||
manager = transcriberManager,
|
||||
listener = transcriberListener,
|
||||
): void {
|
||||
if (manager && listener) {
|
||||
try {
|
||||
manager.removeListener(listener)
|
||||
} catch {
|
||||
// A destroyed call engine has already released the listener.
|
||||
}
|
||||
}
|
||||
if (transcriberManager === manager && transcriberListener === listener) {
|
||||
transcriberManager = null
|
||||
transcriberListener = null
|
||||
}
|
||||
}
|
||||
|
||||
function transcriptionTokenIsCurrent(token: number, sessionId: string): boolean {
|
||||
return (
|
||||
token === transcriptionGeneration
|
||||
&& sessionId === transcriptionSessionId
|
||||
&& transcriptionState.value === 'starting'
|
||||
&& phase.value === 'connected'
|
||||
&& !endNotified
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPendingSegments(timeoutMs = 3000, quietMs = 300): Promise<boolean> {
|
||||
const startedAt = Date.now()
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const stoppedOrSettled = transcriberStoppedAt > 0 || Date.now() - startedAt >= 500
|
||||
const quiet = Date.now() - lastTranscriberMessageAt >= quietMs
|
||||
if (stoppedOrSettled && quiet && pendingSegments.size === 0) return true
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function performStartTranscription(): Promise<void> {
|
||||
if (!activeConfig || phase.value !== 'connected') throw new Error('视频接通后才能开始录音')
|
||||
if (transcriptionState.value !== 'idle' && transcriptionState.value !== 'error') {
|
||||
throw new Error('录音任务正在处理中')
|
||||
}
|
||||
|
||||
transcriptionState.value = 'starting'
|
||||
const sessionId = newTranscriptionSessionId()
|
||||
const token = ++transcriptionGeneration
|
||||
transcriptionSessionId = sessionId
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
lastTranscriberMessageAt = Date.now()
|
||||
transcriberStoppedAt = 0
|
||||
notice.value = '正在准备录音文字存储…'
|
||||
const storageReady = await requestTranscriptionStart(sessionId)
|
||||
if (!storageReady) {
|
||||
if (!transcriptionTokenIsCurrent(token, sessionId)) return
|
||||
transcriptionState.value = 'error'
|
||||
transcriptionSessionId = ''
|
||||
throw new Error(notice.value || '服务端无法保存本次面诊对话文字')
|
||||
}
|
||||
if (!transcriptionTokenIsCurrent(token, sessionId)) {
|
||||
await requestTranscriptionStop(sessionId, 'partial')
|
||||
return
|
||||
}
|
||||
|
||||
let ownedManager: RealtimeTranscriberManager | null = null
|
||||
let ownedListener: RealtimeTranscriberListener | null = null
|
||||
try {
|
||||
const subscribed = subscribeTranscriber()
|
||||
const { manager, listener } = subscribed
|
||||
ownedManager = manager
|
||||
ownedListener = listener
|
||||
const robotId = await manager.startRealtimeTranscriber({
|
||||
sourceLanguage: 'zh',
|
||||
})
|
||||
if (!robotId) throw new Error('当前腾讯云项目未开通实时语音转写')
|
||||
if (!transcriptionTokenIsCurrent(token, sessionId)) {
|
||||
await manager.stopRealtimeTranscriber(robotId)
|
||||
unsubscribeTranscriber(manager, listener)
|
||||
return
|
||||
}
|
||||
transcriberRobotId = robotId
|
||||
transcriberRunning = true
|
||||
transcriptionState.value = 'recording'
|
||||
notice.value = '正在录音并实时转换为对话文字'
|
||||
} catch (error) {
|
||||
unsubscribeTranscriber(ownedManager, ownedListener)
|
||||
await requestTranscriptionStop(sessionId, 'failed')
|
||||
if (sessionId === transcriptionSessionId) transcriptionState.value = 'error'
|
||||
const message = safeErrorMessage(error, '录音转文字启动失败')
|
||||
notice.value = message
|
||||
if (sessionId === transcriptionSessionId) transcriptionSessionId = ''
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function startTranscription(): Promise<void> {
|
||||
if (transcriptionStartPromise) return transcriptionStartPromise
|
||||
const operation = performStartTranscription()
|
||||
const tracked = operation.finally(() => {
|
||||
if (transcriptionStartPromise === tracked) transcriptionStartPromise = null
|
||||
})
|
||||
transcriptionStartPromise = tracked
|
||||
return transcriptionStartPromise
|
||||
}
|
||||
|
||||
async function stopTranscription(
|
||||
status: 'completed' | 'partial' | 'failed' = 'completed',
|
||||
managerAlreadyStopped = false,
|
||||
): Promise<void> {
|
||||
if (transcriptionStopPromise) return transcriptionStopPromise
|
||||
if (transcriptionState.value === 'idle') return
|
||||
if (!transcriptionSessionId) {
|
||||
transcriptionState.value = 'idle'
|
||||
return
|
||||
}
|
||||
const sessionId = transcriptionSessionId
|
||||
const startInFlight = transcriptionStartPromise
|
||||
transcriptionGeneration += 1
|
||||
transcriptionState.value = 'stopping'
|
||||
transcriptionStopPromise = (async () => {
|
||||
if (startInFlight) {
|
||||
try {
|
||||
await startInFlight
|
||||
} catch {
|
||||
// Its failure is materialized as a failed/partial transcript below.
|
||||
}
|
||||
}
|
||||
let sdkStopped = managerAlreadyStopped
|
||||
const manager = transcriberManager
|
||||
const robotId = transcriberRobotId
|
||||
if (!managerAlreadyStopped && transcriberRunning && manager && robotId) {
|
||||
try {
|
||||
await manager.stopRealtimeTranscriber(robotId)
|
||||
sdkStopped = true
|
||||
} catch (error) {
|
||||
console.warn('[doctor-consultation] 停止实时转写失败', safeErrorMessage(error))
|
||||
}
|
||||
}
|
||||
transcriberRunning = false
|
||||
transcriberRobotId = ''
|
||||
const segmentsComplete = await waitForPendingSegments()
|
||||
unsubscribeTranscriber()
|
||||
const finalStatus = sdkStopped && segmentsComplete ? status : 'partial'
|
||||
const persisted = await requestTranscriptionStop(sessionId, finalStatus)
|
||||
if (sessionId !== transcriptionSessionId) return
|
||||
transcriptionState.value = persisted ? 'idle' : 'error'
|
||||
notice.value = persisted
|
||||
? '本次面诊对话文字已保存到视频记录'
|
||||
: '对话文字未能完整确认保存,请稍后在面诊记录中检查'
|
||||
transcriptionSessionId = ''
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
})().finally(() => {
|
||||
transcriptionStopPromise = null
|
||||
})
|
||||
return transcriptionStopPromise
|
||||
}
|
||||
|
||||
function transcriptionResult(
|
||||
operation: 'start' | 'segment' | 'stop',
|
||||
sessionId: string,
|
||||
segmentId: string,
|
||||
ok: boolean,
|
||||
message: string,
|
||||
): void {
|
||||
if (operation === 'start') {
|
||||
const pending = pendingTranscriptionStarts.get(sessionId)
|
||||
if (!pending) return
|
||||
pendingTranscriptionStarts.delete(sessionId)
|
||||
if (message && sessionId === transcriptionSessionId) notice.value = message
|
||||
pending.resolve(Boolean(ok))
|
||||
} else if (operation === 'stop') {
|
||||
const pending = pendingTranscriptionStops.get(sessionId)
|
||||
if (!pending) return
|
||||
pendingTranscriptionStops.delete(sessionId)
|
||||
if (message && sessionId === transcriptionSessionId) notice.value = message
|
||||
pending.resolve(Boolean(ok))
|
||||
} else if (operation === 'segment' && sessionId === transcriptionSessionId) {
|
||||
const pending = pendingSegments.get(segmentId)
|
||||
if (!pending) return
|
||||
if (ok) {
|
||||
pendingSegments.delete(segmentId)
|
||||
acknowledgedSegmentIds.add(segmentId)
|
||||
} else if (pending.attempts < 3) {
|
||||
pending.attempts += 1
|
||||
window.setTimeout(() => {
|
||||
if (pendingSegments.get(segmentId) === pending) emit(pending.message)
|
||||
}, pending.attempts * 250)
|
||||
} else {
|
||||
notice.value = message || '部分对话文字保存失败,本次记录将标记为部分保存'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyHangup(status = 'ended'): Promise<void> {
|
||||
if (hangupNotification) return hangupNotification
|
||||
if (endNotified) return Promise.resolve()
|
||||
endNotified = true
|
||||
phase.value = 'ended'
|
||||
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'hangup',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
status,
|
||||
})
|
||||
hangupNotification = (async () => {
|
||||
try {
|
||||
if (transcriptionState.value !== 'idle') await stopTranscription('completed')
|
||||
} catch (error) {
|
||||
notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
|
||||
console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
|
||||
} finally {
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'hangup',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
status,
|
||||
})
|
||||
}
|
||||
})()
|
||||
return hangupNotification
|
||||
}
|
||||
|
||||
function readRoomId(): string {
|
||||
@@ -498,21 +935,31 @@ function handleStatusChanged(payload: unknown): void {
|
||||
: payload
|
||||
const status = typeof value === 'string' ? value : 'unknown'
|
||||
if (status === 'connected' || status.startsWith('calling-')) {
|
||||
if (endNotified || !activeConfig) return
|
||||
const cycle = callCycleGeneration
|
||||
phase.value = 'connected'
|
||||
statusText.value = '视频问诊进行中'
|
||||
void pollRoomId()
|
||||
if (autoTranscriptionAttemptedGeneration !== cycle) {
|
||||
autoTranscriptionAttemptedGeneration = cycle
|
||||
void startTranscription().catch((error) => {
|
||||
if (cycle !== callCycleGeneration || endNotified) return
|
||||
notice.value = safeErrorMessage(error, '自动录音转文字启动失败')
|
||||
})
|
||||
}
|
||||
} else if (status === 'calling' || status.startsWith('dialing')) {
|
||||
if (endNotified || !activeConfig) return
|
||||
phase.value = 'dialing'
|
||||
statusText.value = '正在等待患者接听'
|
||||
} else if (status === 'idle' && activeConfig && !starting) {
|
||||
notifyHangup(status)
|
||||
void notifyHangup(status)
|
||||
}
|
||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
|
||||
}
|
||||
|
||||
TUICallKitAPI.setCallback({
|
||||
statusChanged: handleStatusChanged,
|
||||
afterCalling: () => notifyHangup('after-calling'),
|
||||
afterCalling: () => { void notifyHangup('after-calling') },
|
||||
})
|
||||
TUICallKitAPI.setLanguage('zh-cn')
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
@@ -543,11 +990,17 @@ async function startVideo(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
starting = true
|
||||
endNotified = false
|
||||
phase.value = 'starting'
|
||||
statusText.value = '正在创建安全视频通话'
|
||||
notice.value = ''
|
||||
try {
|
||||
if (hangupNotification) await hangupNotification
|
||||
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
hangupNotification = null
|
||||
callCycleGeneration += 1
|
||||
endNotified = false
|
||||
phase.value = 'starting'
|
||||
statusText.value = '正在创建安全视频通话'
|
||||
transcriptionState.value = 'idle'
|
||||
transcriptionSessionId = ''
|
||||
notice.value = ''
|
||||
const allowed = await requestHostCallStart()
|
||||
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
||||
await TUICallKitAPI.init({
|
||||
@@ -579,10 +1032,14 @@ async function startVideo(): Promise<void> {
|
||||
}
|
||||
|
||||
async function hangup(): Promise<void> {
|
||||
if (!activeConfig || endNotified) return
|
||||
if (!activeConfig) return
|
||||
if (endNotified) {
|
||||
if (hangupNotification) await hangupNotification
|
||||
return
|
||||
}
|
||||
try {
|
||||
await TUICallKitAPI.hangup()
|
||||
notifyHangup('local-hangup')
|
||||
await notifyHangup('local-hangup')
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, '结束视频通话失败')
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
@@ -610,7 +1067,17 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
nextReqMessageID = ''
|
||||
hasMoreMessages.value = false
|
||||
endNotified = true
|
||||
hangupNotification = null
|
||||
callCycleGeneration += 1
|
||||
autoTranscriptionAttemptedGeneration = -1
|
||||
phase.value = 'ready'
|
||||
transcriptionState.value = 'idle'
|
||||
transcriptionSessionId = ''
|
||||
transcriptionGeneration += 1
|
||||
transcriberRunning = false
|
||||
transcriberRobotId = ''
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
notice.value = ''
|
||||
if (activeConfig.mode === 'chat') {
|
||||
try {
|
||||
@@ -629,6 +1096,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
|
||||
async function close(): Promise<void> {
|
||||
if (!endNotified) await hangup()
|
||||
if (hangupNotification) await hangupNotification
|
||||
unsubscribeTranscriber()
|
||||
transcriptionGeneration += 1
|
||||
for (const pending of pendingTranscriptionStarts.values()) pending.resolve(false)
|
||||
for (const pending of pendingTranscriptionStops.values()) pending.resolve(false)
|
||||
pendingTranscriptionStarts.clear()
|
||||
pendingTranscriptionStops.clear()
|
||||
await logoutChat()
|
||||
activeConfig = null
|
||||
phase.value = 'ended'
|
||||
@@ -641,6 +1115,7 @@ window.doctorConsultation = {
|
||||
hangup,
|
||||
hostCallReady,
|
||||
screenshotResult,
|
||||
transcriptionResult,
|
||||
}
|
||||
window.doctorCall = { start: open, hangup }
|
||||
initializeQtWebChannel()
|
||||
@@ -655,6 +1130,7 @@ createApp(App, {
|
||||
chatBusy: readonly(chatBusy),
|
||||
notice: readonly(notice),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
onSendText: sendText,
|
||||
onSendAttachment: sendAttachment,
|
||||
onLoadMore: () => loadMessages(true),
|
||||
|
||||
@@ -294,6 +294,31 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
gap: 10px;
|
||||
}
|
||||
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
|
||||
.recording-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid rgba(255, 255, 255, .24);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: rgba(22, 29, 39, .88);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recording-status--active { border-color: rgba(244, 99, 115, .64); background: rgba(126, 35, 50, .9); }
|
||||
.recording-status--error { border-color: rgba(242, 109, 109, .56); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||
.recording-indicator {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f46373;
|
||||
box-shadow: 0 0 0 4px rgba(244, 99, 115, .16);
|
||||
}
|
||||
.recording-status--active .recording-indicator { animation: recording-pulse 1.25s ease-in-out infinite; }
|
||||
@keyframes recording-pulse {
|
||||
50% { box-shadow: 0 0 0 8px rgba(244, 99, 115, .04); opacity: .72; }
|
||||
}
|
||||
.hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); }
|
||||
.hangup-button:hover { background: #be394d; }
|
||||
.video-notice {
|
||||
|
||||
Reference in New Issue
Block a user