diff --git a/admin/scripts/verify-appointment-type.cjs b/admin/scripts/verify-appointment-type.cjs index 53e31c5e1..33a95979b 100644 --- a/admin/scripts/verify-appointment-type.cjs +++ b/admin/scripts/verify-appointment-type.cjs @@ -27,6 +27,7 @@ const callers = [ ] for (const caller of callers) { assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/) + assert.match(read(caller), /appointmentId: Number\(row\.id\)/) } const chat = read('components/chat-dialog/index.vue') assert.match(chat, /appointmentType\.value = data\.appointmentType/) @@ -40,7 +41,8 @@ assert.match(form, /appointment_type: form\.appointmentType/) async function main() { const components = [ ...callers, 'components/chat-dialog/index.vue', - 'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue' + 'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue', + 'views/first_visit/my_patients/index.vue', 'views/tcm/diagnosis/index.vue', 'views/tcm/diagnosis/index_h5.vue' ] for (const filename of components) { const { descriptor, errors } = parse(read(filename), { filename }) @@ -62,4 +64,4 @@ async function main() { } console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`) } -main().catch((error) => { console.error(error); process.exitCode = 1 }) \ No newline at end of file +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 795e588ad..e62b0277b 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -275,14 +275,52 @@ export function generateOrderQrcode(params: any) { // ========== IM / 企业微信聊天记录 ========== -/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_*) */ -export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) { - return request.get({ url: '/tcm.diagnosis/getImChatMessages', params }) +export interface ImChatMessagesResponse { + lists: any[] + patient_im_id?: string + patient_name?: string + sync_error?: string } -/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */ -export function triggerImChatSync(data: { diagnosis_id: number }) { - return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data }) +export interface ImChatSyncProgress { + sync_token?: string + completed: boolean + phase?: 'checking_accounts' | 'syncing' | 'completed' + checked_accounts?: number + candidate_accounts?: number + skipped_accounts?: number + inserted: number + processed_peers: number + total_peers: number + error?: string + errors?: string[] +} + +/** 保留接口错误原因,并由面板决定何时提示同步完成。 */ +function unwrapImChatResponse(response: { code: number; data: T; msg?: string }): T { + if (Number(response?.code) !== 1) { + const detail = response?.data as any + throw new Error(response?.msg || detail?.error || detail?.message || '聊天记录请求失败') + } + return response.data +} + +/** 当前患者所有诊单的 IM 归档消息。 */ +export async function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }): Promise { + const response = await request.get( + { url: '/tcm.diagnosis/getImChatMessages', params, timeout: 30000 }, + { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 } + ) + return unwrapImChatResponse(response) +} + +/** 同步一页云端消息,继续传入返回的 token,直到 completed 为 true。 */ +export async function triggerImChatSync(data: { diagnosis_id: number; sync_token?: string; scope?: 'current' }): Promise { + const response = await request.post( + { url: '/tcm.diagnosis/triggerImChatSync', data, timeout: 30000 }, + { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 } + ) + return unwrapImChatResponse(response) } // 获取企业微信聊天记录 diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index 23278540d..3ba575ff0 100644 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -55,18 +55,19 @@ - + - + 群视频 + 图文问诊,不支持音视频通话 @@ -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('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(null) const diagnosisId = ref(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 | undefined +let chatArchiveDebounce: ReturnType | 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; diff --git a/admin/src/utils/appointment-call-guard.ts b/admin/src/utils/appointment-call-guard.ts new file mode 100644 index 000000000..17bf2f90a --- /dev/null +++ b/admin/src/utils/appointment-call-guard.ts @@ -0,0 +1,19 @@ +type OutgoingCallGuard = (callType: number) => Promise + +// 通话 SDK 为全局单例;使用当前窗口的校验器,避免首个组件闭包保留旧患者类型。 +let activeGuard: OutgoingCallGuard | undefined + +export function registerAppointmentCallGuard(guard: OutgoingCallGuard): () => void { + activeGuard = guard + return () => { + if (activeGuard === guard) activeGuard = undefined + } +} + +export async function checkAppointmentOutgoingCall(params: unknown): Promise { + const guard = activeGuard + if (!guard) throw new Error('请先打开本次挂号的聊天窗口') + const type = params && typeof params === 'object' && 'type' in params ? Number(params.type) : 2 + await guard(type === 1 ? 1 : 2) + if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话') +} diff --git a/admin/src/utils/appointment-type.ts b/admin/src/utils/appointment-type.ts index 33a5f212a..57f94cbbf 100644 --- a/admin/src/utils/appointment-type.ts +++ b/admin/src/utils/appointment-type.ts @@ -6,3 +6,8 @@ export function appointmentTypeDescription(value?: string | null): string { if (value === 'phone') return '电话问诊' return '未知' } + +/** 图文及未知问诊方式不提供视频;只有已存在挂号的历史空值沿用视频。 */ +export function canAppointmentVideoCall(value?: string | null): boolean { + return value == null || value.trim() === '' || value === 'video' +} diff --git a/admin/src/utils/im-chat-archive-trigger.ts b/admin/src/utils/im-chat-archive-trigger.ts new file mode 100644 index 000000000..67c9de441 --- /dev/null +++ b/admin/src/utils/im-chat-archive-trigger.ts @@ -0,0 +1,42 @@ +/** 合并聊天窗口的收发事件;每次同步只接受后端读取的云端消息,不上报客户端正文。 */ +export function createImChatArchiveTrigger( + syncPage: (params: { diagnosis_id: number; scope: 'current'; sync_token?: string }) => Promise<{ + sync_token?: string; completed: boolean; errors?: string[] + }>, + onError: (error: unknown) => void = () => {} +) { + const jobs = new Map }>() + return (diagnosisId: number): Promise => { + if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) return Promise.resolve() + const pending = jobs.get(diagnosisId) + if (pending) { + pending.again = true + return pending.promise + } + const job = { again: false, promise: Promise.resolve() } + jobs.set(diagnosisId, job) + job.promise = (async () => { + try { + do { + job.again = false + let token: string | undefined + for (;;) { + const result = await syncPage({ diagnosis_id: diagnosisId, scope: 'current', sync_token: token }) + if (result.completed) { + if (result.errors?.length) throw new Error(result.errors.join(';')) + break + } + if (!result.sync_token) throw new Error('聊天记录同步未返回进度') + token = result.sync_token + } + // 同步期间收到新消息时再追一次,覆盖新消息晚于本轮首页的情况。 + } while (job.again) + } catch (error) { + onError(error) + } finally { + jobs.delete(diagnosisId) + } + })() + return job.promise + } +} diff --git a/admin/src/utils/im-chat-history.ts b/admin/src/utils/im-chat-history.ts new file mode 100644 index 000000000..55c85e088 --- /dev/null +++ b/admin/src/utils/im-chat-history.ts @@ -0,0 +1,245 @@ +import { reactive } from 'vue' +import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm' + +type NoticeKind = 'success' | 'warning' | 'error' +type TimerHandle = ReturnType + +interface HistoryDependencies { + load: (diagnosisId: number) => Promise + sync: (diagnosisId: number, token?: string) => Promise + notify?: (kind: NoticeKind, message: string) => void + now?: () => number + setTimer?: (callback: () => void, delay: number) => TimerHandle + clearTimer?: (timer: TimerHandle) => void + visible?: boolean +} + +interface Session { + diagnosisId: number + generation: number + archiveRequest?: Promise + syncRequest?: Promise +} + +/** Each visible diagnosis owns its requests and one timer; late responses cannot update another patient. */ +export function createImChatHistory(deps: HistoryDependencies) { + const state = reactive({ + rows: [] as any[], + patientImId: '', + patientName: '', + loading: false, + syncing: false, + hasLoaded: false, + readError: '', + syncError: '', + partialErrors: [] as string[], + inserted: 0, + processedPeers: 0, + totalPeers: 0, + phase: '', + checkedAccounts: 0, + candidateAccounts: 0, + skippedAccounts: 0, + lastSyncAt: null as number | null + }) + const now = deps.now ?? Date.now + const setTimer = deps.setTimer ?? setTimeout + const clearTimer = deps.clearTimer ?? clearTimeout + let diagnosisId = 0 + let generation = 0 + let visible = deps.visible ?? true + let disposed = false + let session: Session | undefined + let timer: TimerHandle | undefined + + function current(target: Session) { + return !disposed && visible && target === session && target.generation === generation && target.diagnosisId === diagnosisId + } + + function clearScheduledSync() { + if (timer !== undefined) clearTimer(timer) + timer = undefined + } + + function invalidate() { + generation++ + clearScheduledSync() + session = undefined + state.loading = false + state.syncing = false + } + + async function loadArchived(target: Session, freshAfterPending = false): Promise { + if (!current(target)) return false + if (target.archiveRequest) { + const result = await target.archiveRequest + if (!freshAfterPending || !current(target)) return result + return loadArchived(target) + } + state.loading = true + const request = Promise.resolve().then(async () => { + if (!current(target)) return false + try { + const response = await deps.load(target.diagnosisId) + if (!current(target)) return false + if (!Array.isArray(response?.lists)) throw new Error('聊天记录接口返回的数据不完整') + state.rows = response.lists + state.patientImId = response.patient_im_id || '' + state.patientName = response.patient_name || '' + state.hasLoaded = true + state.readError = '' + return true + } catch (error) { + if (current(target)) state.readError = imChatErrorMessage(error, '读取聊天记录失败') + return false + } + }) + target.archiveRequest = request + try { + return await request + } finally { + if (target.archiveRequest === request) target.archiveRequest = undefined + if (current(target)) state.loading = false + } + } + + function scheduleSync(target: Session) { + clearScheduledSync() + if (!current(target)) return + timer = setTimer(() => { + timer = undefined + if (current(target)) void sync(target) + }, 30000) + } + + async function sync(target: Session, manual = false): Promise { + if (!current(target)) return + if (target.syncRequest) return target.syncRequest + clearScheduledSync() + state.syncing = true + state.syncError = '' + state.partialErrors = [] + state.inserted = 0 + state.processedPeers = 0 + state.totalPeers = 0 + state.phase = 'checking_accounts' + state.checkedAccounts = 0 + state.candidateAccounts = 0 + state.skippedAccounts = 0 + const request = Promise.resolve().then(async () => { + let token: string | undefined + let pagesSinceRefresh = 0 + let lastRefreshAt = now() + const errors = new Set() + try { + while (current(target)) { + const progress = await deps.sync(target.diagnosisId, token) + if (!current(target)) return + if (typeof progress?.completed !== 'boolean') throw new Error('同步接口未返回有效的完成状态') + state.inserted = Number(progress.inserted) || 0 + state.processedPeers = Number(progress.processed_peers) || 0 + state.totalPeers = Number(progress.total_peers) || 0 + state.phase = progress.phase || 'syncing' + state.checkedAccounts = Number(progress.checked_accounts) || 0 + state.candidateAccounts = Number(progress.candidate_accounts) || 0 + state.skippedAccounts = Number(progress.skipped_accounts) || 0 + for (const error of [progress.error, ...(progress.errors || [])]) { + if (typeof error === 'string' && error.trim()) errors.add(error) + } + state.partialErrors = [...errors] + if (progress.completed) { + const refreshed = await loadArchived(target, true) + if (!current(target)) return + state.lastSyncAt = now() + if (manual) { + if (errors.size) deps.notify?.('warning', `同步完成,部分会话失败:${[...errors].join(';')}`) + else if (!refreshed) deps.notify?.('warning', `同步已完成,但读取聊天记录失败:${state.readError}`) + else deps.notify?.('success', `聊天记录已更新,本次新增 ${state.inserted} 条`) + } + return + } + if (!progress.sync_token) throw new Error('同步接口未返回继续同步所需的进度标识') + token = progress.sync_token + pagesSinceRefresh++ + if (pagesSinceRefresh >= 3 || now() - lastRefreshAt >= 2000) { + await loadArchived(target, true) + pagesSinceRefresh = 0 + lastRefreshAt = now() + } + } + } catch (error) { + if (!current(target)) return + state.syncError = imChatErrorMessage(error, '同步聊天记录失败') + if (manual) deps.notify?.('error', state.syncError) + } + }) + target.syncRequest = request + try { + await request + } finally { + if (target.syncRequest === request) target.syncRequest = undefined + if (current(target)) { + state.syncing = false + scheduleSync(target) + } + } + } + + function start() { + if (disposed || !visible || !diagnosisId) return + const target: Session = { diagnosisId, generation } + session = target + void loadArchived(target) + void sync(target) + } + + function setDiagnosis(value: number) { + const nextId = Number(value) > 0 ? Number(value) : 0 + if (disposed || (nextId === diagnosisId && session)) return + invalidate() + diagnosisId = nextId + state.rows = [] + state.patientImId = '' + state.patientName = '' + state.hasLoaded = false + state.readError = '' + state.syncError = '' + state.partialErrors = [] + state.inserted = 0 + state.processedPeers = 0 + state.totalPeers = 0 + state.phase = '' + state.checkedAccounts = 0 + state.candidateAccounts = 0 + state.skippedAccounts = 0 + state.lastSyncAt = null + start() + } + + function setVisible(value: boolean) { + if (disposed || value === visible) return + visible = value + invalidate() + if (visible) start() + } + + function dispose() { + disposed = true + invalidate() + } + + return { + state, + setDiagnosis, + setVisible, + dispose, + reload: () => session ? loadArchived(session) : Promise.resolve(false), + sync: () => session ? sync(session, true) : Promise.resolve() + } +} + +export function imChatErrorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string' && error.trim()) return error + const detail = error as any + return detail?.response?.data?.msg || detail?.msg || detail?.error || detail?.message || fallback +} diff --git a/admin/src/views/consumer/prescription/guahao.vue b/admin/src/views/consumer/prescription/guahao.vue index d2d70effb..76bbeaf14 100644 --- a/admin/src/views/consumer/prescription/guahao.vue +++ b/admin/src/views/consumer/prescription/guahao.vue @@ -42,6 +42,13 @@ /> + + + + + + + @@ -115,7 +122,9 @@
{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}
- + + + - + + + + - @@ -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; diff --git a/admin/src/views/tcm/diagnosis/index.vue b/admin/src/views/tcm/diagnosis/index.vue index 2d65d6cb7..7a1c189ed 100644 --- a/admin/src/views/tcm/diagnosis/index.vue +++ b/admin/src/views/tcm/diagnosis/index.vue @@ -259,7 +259,20 @@ - + + + + 未挂号 @@ -299,7 +301,7 @@ 视频二维码