更新
This commit is contained in:
@@ -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 })
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
|
||||
+44
-6
@@ -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<T>(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<ImChatMessagesResponse> {
|
||||
const response = await request.get(
|
||||
{ url: '/tcm.diagnosis/getImChatMessages', params, timeout: 30000 },
|
||||
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||
)
|
||||
return unwrapImChatResponse<ImChatMessagesResponse>(response)
|
||||
}
|
||||
|
||||
/** 同步一页云端消息,继续传入返回的 token,直到 completed 为 true。 */
|
||||
export async function triggerImChatSync(data: { diagnosis_id: number; sync_token?: string; scope?: 'current' }): Promise<ImChatSyncProgress> {
|
||||
const response = await request.post(
|
||||
{ url: '/tcm.diagnosis/triggerImChatSync', data, timeout: 30000 },
|
||||
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||
)
|
||||
return unwrapImChatResponse<ImChatSyncProgress>(response)
|
||||
}
|
||||
|
||||
// 获取企业微信聊天记录
|
||||
|
||||
@@ -55,18 +55,19 @@
|
||||
<ImagePicker />
|
||||
<FilePicker />
|
||||
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
||||
<AudioCallPicker v-if="isCallReady" />
|
||||
<AudioCallPicker v-if="isCallReady && canAudioCall" />
|
||||
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.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;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
type OutgoingCallGuard = (callType: number) => Promise<void>
|
||||
|
||||
// 通话 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<void> {
|
||||
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('当前问诊已切换,请重新发起通话')
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<number, { again: boolean; promise: Promise<void> }>()
|
||||
return (diagnosisId: number): Promise<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm'
|
||||
|
||||
type NoticeKind = 'success' | 'warning' | 'error'
|
||||
type TimerHandle = ReturnType<typeof setTimeout>
|
||||
|
||||
interface HistoryDependencies {
|
||||
load: (diagnosisId: number) => Promise<ImChatMessagesResponse>
|
||||
sync: (diagnosisId: number, token?: string) => Promise<ImChatSyncProgress>
|
||||
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<boolean>
|
||||
syncRequest?: Promise<void>
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
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<void> {
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -42,6 +42,13 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="挂号类型">
|
||||
<el-select v-model="queryParams.appointment_type" placeholder="全部" clearable class="!w-[140px]">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
||||
<el-option label="已预约" :value="1" />
|
||||
@@ -115,7 +122,9 @@
|
||||
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
|
||||
<el-table-column label="挂号类型" width="110">
|
||||
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
||||
@@ -178,7 +187,7 @@
|
||||
<el-radio-button label="all">全天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="问诊类型" required>
|
||||
<el-form-item label="挂号类型" required>
|
||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
@@ -292,6 +301,7 @@
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
@@ -302,6 +312,7 @@ const queryParams = reactive({
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
appointment_type: '',
|
||||
status: '' as number | '',
|
||||
channel_source: '' as string
|
||||
})
|
||||
@@ -312,6 +323,7 @@ const queryInit = {
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
appointment_type: '',
|
||||
status: '' as number | '',
|
||||
channel_source: ''
|
||||
}
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="165">
|
||||
<el-table-column label="挂号类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.appointment_id) > 0 ? appointmentTypeDescription(row.appointment_type) : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="165">
|
||||
<template #default="{ row }">
|
||||
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
||||
{{ row.appointment_time_text || '暂无预约' }}
|
||||
@@ -324,7 +329,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -451,12 +451,14 @@ const handleCall = async (row: QueueRow) => {
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
appointment_id: Number(row.id),
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res
|
||||
})
|
||||
|
||||
@@ -163,7 +163,9 @@
|
||||
</el-table-column>
|
||||
|
||||
|
||||
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
|
||||
<el-table-column label="面诊类型" width="110">
|
||||
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="确认诊单" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -219,8 +221,9 @@
|
||||
</el-button>
|
||||
|
||||
<template v-if="row.status !== 3">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/videoQr']"
|
||||
<el-button
|
||||
v-if="canAppointmentVideoCall(row.appointment_type)"
|
||||
v-perms="['tcm.diagnosis/videoQr']"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@@ -237,7 +240,7 @@
|
||||
size="small"
|
||||
@click="handleChat(row)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/complete']"
|
||||
@@ -340,8 +343,8 @@
|
||||
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">预约类型</span>
|
||||
<span class="detail-value">{{ detailData.appointment_type_desc }}</span>
|
||||
<span class="detail-label">面诊类型</span>
|
||||
<span class="detail-value">{{ appointmentTypeDescription(detailData.appointment_type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,7 +558,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
@@ -1012,7 +1016,8 @@ const handleChat = async (row: any) => {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
diagnosis_id: diagnosisId,
|
||||
appointment_id: Number(row.id)
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
@@ -1022,6 +1027,7 @@ const handleChat = async (row: any) => {
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
@@ -1032,7 +1038,11 @@ const handleChat = async (row: any) => {
|
||||
}
|
||||
|
||||
// 生成小程序二维码(跳转小程序路径:pages/login/login)
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!canAppointmentVideoCall(row.appointment_type)) {
|
||||
feedback.msgWarning('仅视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
|
||||
@@ -108,8 +108,12 @@
|
||||
<span class="apt-h5-row-key">预约</span>
|
||||
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">医生</span>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">面诊类型</span>
|
||||
<span class="apt-h5-row-val">{{ appointmentTypeDescription(row.appointment_type) }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">医生</span>
|
||||
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
@@ -147,7 +151,7 @@
|
||||
:icon="Phone"
|
||||
@click="handleChat(row)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
|
||||
@@ -179,7 +183,7 @@
|
||||
>
|
||||
<div v-if="moreSheetRow" class="apt-h5-actions">
|
||||
<button
|
||||
v-if="moreSheetRow.status !== 3 && hasPermission(['tcm.diagnosis/videoQr'])"
|
||||
v-if="moreSheetRow.status !== 3 && canAppointmentVideoCall(moreSheetRow.appointment_type) && hasPermission(['tcm.diagnosis/videoQr'])"
|
||||
class="apt-h5-action"
|
||||
@click="runAction('videoQr')"
|
||||
>
|
||||
@@ -236,7 +240,7 @@
|
||||
<div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div>
|
||||
<div><span>预约时间</span><strong>{{ detailData.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div>
|
||||
<div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</strong></div>
|
||||
<div><span>预约类型</span><strong>{{ detailData.appointment_type_desc || '-' }}</strong></div>
|
||||
<div><span>面诊类型</span><strong>{{ appointmentTypeDescription(detailData.appointment_type) }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -336,7 +340,7 @@
|
||||
plain
|
||||
@click="handleChat(detailActionRow)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(detailActionRow.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
|
||||
@@ -407,7 +411,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmAppointmentH5">
|
||||
<script setup lang="ts" name="tcmAppointmentH5">
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
import {
|
||||
@@ -656,12 +661,14 @@ const handleChat = async (row: any) => {
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
diagnosis_id: diagnosisId,
|
||||
appointment_id: Number(row.id)
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res
|
||||
})
|
||||
@@ -777,7 +784,11 @@ const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
const qrcodeUrl = ref('')
|
||||
const currentQRCodeRow = ref<any>(null)
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!canAppointmentVideoCall(row.appointment_type)) {
|
||||
feedback.msgWarning('仅视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
@@ -1184,8 +1195,9 @@ onUnmounted(() => {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.apt-h5-row-key {
|
||||
flex: 0 0 38px;
|
||||
.apt-h5-row-key {
|
||||
flex: 0 0 60px;
|
||||
white-space: nowrap;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { renderToString } = require('@vue/server-renderer')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const src = path.join(__dirname, '../src')
|
||||
function moduleFrom(source, mockRequire = require, browserWindow = {}) {
|
||||
const code = ts.transpileModule(source.replaceAll('import.meta.env.DEV', 'false'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', 'window', code)(mockRequire, module, module.exports, browserWindow)
|
||||
return module.exports
|
||||
}
|
||||
const read = (file) => fs.readFileSync(path.join(src, file), 'utf8')
|
||||
const types = moduleFrom(read('utils/appointment-type.ts'))
|
||||
const { descriptor } = parse(read('components/chat-dialog/index.vue'))
|
||||
const script = compileScript(descriptor, { id: 'appointment-call-mode' })
|
||||
const template = compileTemplate({ source: descriptor.template.content, filename: 'chat-dialog.vue', id: 'appointment-call-mode', compilerOptions: { bindingMetadata: script.bindings } })
|
||||
assert.deepEqual(template.errors, [])
|
||||
const render = moduleFrom(template.code).render
|
||||
const passthrough = { setup: (_, { slots }) => () => vue.h('div', [slots.default?.(), slots.headerToolbar?.()]) }
|
||||
const picker = text => ({ setup: () => () => vue.h('button', text) })
|
||||
|
||||
function chat(policy = {}) {
|
||||
const requests = [], warnings = [], sdkCalls = []
|
||||
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
|
||||
const server = Object.fromEntries(['calls', 'call', 'groupCall'].map(name => [name, async params => { sdkCalls.push({ name, params }) }]))
|
||||
const api = { getCallSignature: async params => { requests.push(params); return policy } }
|
||||
const result = moduleFrom(script.content, name => {
|
||||
if (name === 'vue') return { ...vue, onMounted() {}, onUnmounted() {} }
|
||||
if (name === '@/utils/appointment-type') return types
|
||||
if (name === '@/utils/appointment-call-guard') return guard
|
||||
if (name === '@/utils/im-chat-archive-trigger') return moduleFrom(read('utils/im-chat-archive-trigger.ts'))
|
||||
if (name === '@/api/tcm') return api
|
||||
if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } }
|
||||
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: {} }) }
|
||||
if (name === '@/utils/call-local-recorder') return { CallLocalRecorder: class {} }
|
||||
if (name === '@tencentcloud/chat-uikit-vue3') return {
|
||||
useLoginState: () => ({}), useConversationListState: () => ({ activeConversation: vue.ref(null) }),
|
||||
Chat: passthrough, UIKitProvider: passthrough, MessageInput: passthrough, MessageList: passthrough,
|
||||
EmojiPicker: picker('表情'), ImagePicker: picker('图片'), FilePicker: picker('文件'),
|
||||
AudioCallPicker: picker('语音拨打'), VideoCallPicker: picker('视频拨打')
|
||||
}
|
||||
if (name === '@tencentcloud/call-uikit-vue') return { TUICallKitServer: server, TUICallType: { VIDEO_CALL: 2 }, TUIStore: {}, StoreName: {}, NAME: {} }
|
||||
return {}
|
||||
}).default.setup({}, { expose() {} })
|
||||
result.visible.value = true
|
||||
result.isReady.value = true
|
||||
result.isCallReady.value = true
|
||||
result.patientId.value = 50
|
||||
result.diagnosisId.value = 70
|
||||
result.appointmentId.value = 90
|
||||
result.syncAppointmentCallPolicy(policy)
|
||||
guard.registerAppointmentCallGuard(result.prepareAppointmentCall)
|
||||
return { state: result, guard, server, requests, warnings, sdkCalls, api }
|
||||
}
|
||||
const policy = type => ({ appointment_id: 90, appointment_type: type, appointment_type_desc: types.appointmentTypeDescription(type), can_video_call: type === 'video', can_audio_call: type === 'video' || type === 'phone', call_disabled_reason: type === 'text' ? '图文问诊不支持音视频通话' : '当前挂号不支持该通话方式' })
|
||||
|
||||
test('type labels and video capability preserve legacy video while rejecting text and unknown', () => {
|
||||
for (const value of ['text', 'phone', 'unknown']) assert.equal(types.canAppointmentVideoCall(value), false)
|
||||
for (const value of ['video', '', ' ', null, undefined]) assert.equal(types.canAppointmentVideoCall(value), true)
|
||||
assert.equal(types.appointmentTypeDescription('text'), '图文问诊')
|
||||
})
|
||||
|
||||
test('rendered real chat template keeps text tools while excluding call controls in text mode', async () => {
|
||||
async function markup(type) {
|
||||
const { state } = chat(policy(type))
|
||||
const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) })
|
||||
app.config.warnHandler = () => {}
|
||||
for (const name of ['el-tag', 'el-button', 'el-tooltip', 'el-icon', 'el-alert']) app.component(name, passthrough)
|
||||
const context = {}
|
||||
await renderToString(app, context)
|
||||
return context.teleports.body
|
||||
}
|
||||
const text = await markup('text')
|
||||
assert.match(text, /图文问诊/)
|
||||
for (const tool of ['表情', '图片', '文件']) assert.match(text, new RegExp(tool))
|
||||
assert.doesNotMatch(text, /视频拨打|语音拨打|群视频/)
|
||||
const video = await markup('video')
|
||||
assert.match(video, /视频问诊/)
|
||||
assert.match(video, /视频拨打/)
|
||||
assert.match(video, /群视频/)
|
||||
})
|
||||
|
||||
test('text mode hides all live call entries and refuses direct group invocation', async () => {
|
||||
const { state, requests, sdkCalls } = chat(policy('text'))
|
||||
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
|
||||
assert.equal(state.canVideoCall.value, false)
|
||||
assert.equal(state.canAudioCall.value, false)
|
||||
await state.startGroupVideoCall()
|
||||
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
|
||||
assert.deepEqual(requests, [])
|
||||
assert.deepEqual(sdkCalls, [])
|
||||
assert.match(descriptor.template.content, /<VideoCallPicker v-if="isCallReady && canVideoCall"/)
|
||||
assert.match(descriptor.template.content, /<AudioCallPicker v-if="isCallReady && canAudioCall"/)
|
||||
})
|
||||
|
||||
test('all SDK outgoing paths stop before touching SDK when current appointment is text', async () => {
|
||||
const { state, server, sdkCalls } = chat(policy('text'))
|
||||
state.installTUICallKitRoomHooks()
|
||||
for (const method of ['call', 'calls', 'groupCall']) await assert.rejects(server[method]({ type: 2 }), /图文问诊/)
|
||||
assert.deepEqual(sdkCalls, [])
|
||||
})
|
||||
|
||||
test('video preflight uses actual appointment and prevents a server-side change to text', async () => {
|
||||
const { state, requests, api } = chat(policy('video'))
|
||||
assert.equal(state.canVideoCall.value, true)
|
||||
await state.prepareAppointmentCall(2)
|
||||
assert.deepEqual(requests, [{ patient_id: 50, diagnosis_id: 70, appointment_id: 90 }])
|
||||
api.getCallSignature = async () => policy('text')
|
||||
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
|
||||
assert.equal(state.canVideoCall.value, false)
|
||||
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
|
||||
})
|
||||
|
||||
test('historical phone is audio-only; missing server confirmation never enables video', async () => {
|
||||
const { state } = chat(policy('phone'))
|
||||
assert.equal(state.canAudioCall.value, true)
|
||||
assert.equal(state.canVideoCall.value, false)
|
||||
await state.prepareAppointmentCall(1)
|
||||
await assert.rejects(state.prepareAppointmentCall(2))
|
||||
state.syncAppointmentCallPolicy({ appointment_type: 'video' })
|
||||
assert.equal(state.canVideoCall.value, false)
|
||||
state.syncAppointmentCallPolicy({ appointment_type: null, appointment_type_desc: '未挂号', can_video_call: false, can_audio_call: false })
|
||||
assert.equal(state.appointmentTypeLabel.value, '未挂号')
|
||||
assert.equal(state.canVideoCall.value, false)
|
||||
})
|
||||
|
||||
test('singleton call guard switches to current chat and cancels a pending old context', async () => {
|
||||
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
|
||||
let resolveOld
|
||||
const releaseOld = guard.registerAppointmentCallGuard(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const pending = guard.checkAppointmentOutgoingCall({ type: 2 })
|
||||
const releaseNew = guard.registerAppointmentCallGuard(async () => { throw new Error('图文问诊') })
|
||||
releaseOld()
|
||||
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /图文问诊/)
|
||||
resolveOld()
|
||||
await assert.rejects(pending, /问诊已切换/)
|
||||
releaseNew()
|
||||
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const sql = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [path.join(root, 'server/tests/AppointmentTypeFilterSqlFixture.php')], { encoding: 'utf8' }))
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(`
|
||||
CREATE TABLE doctor_appointment (id INTEGER, patient_id INTEGER, doctor_id INTEGER, assistant_id INTEGER, appointment_type TEXT, status INTEGER, appointment_date TEXT, appointment_time TEXT);
|
||||
CREATE TABLE tcm_diagnosis (id INTEGER, patient_id INTEGER, patient_name TEXT, phone TEXT, gender INTEGER, age INTEGER, weight INTEGER, height INTEGER, assistant_id INTEGER, delete_time INTEGER);
|
||||
CREATE TABLE admin (id INTEGER, name TEXT);
|
||||
INSERT INTO tcm_diagnosis VALUES (100, 1000, '患者甲', '', 1, 30, 60, 170, 301, NULL), (101, 1001, '患者乙', '', 1, 31, 61, 171, 302, NULL), (102, 1002, '已删除', '', 1, 30, 60, 170, 301, 1);
|
||||
INSERT INTO admin VALUES (201, '医生甲'), (202, '医生乙'), (301, '医助甲'), (302, '医助乙');
|
||||
`)
|
||||
const add = db.prepare('INSERT INTO doctor_appointment VALUES (?, ?, ?, 301, ?, ?, ?, ?)')
|
||||
const fixtures = [
|
||||
[1, 100, 201, 'video', 1],
|
||||
[2, 100, 201, null, 1],
|
||||
[3, 100, 201, '', 2],
|
||||
[4, 101, 202, ' ', 3],
|
||||
[5, 101, 202, '\t\r\n\v ', 4],
|
||||
[6, 100, 201, 'text', 1],
|
||||
[7, 101, 202, 'text', 3],
|
||||
[8, 100, 201, 'phone', 1],
|
||||
[9, 100, 201, 'unknown', 1],
|
||||
[10, 100, 201, 'Video', 1],
|
||||
[11, 100, 201, 'video ', 1],
|
||||
[12, 102, 201, null, 1],
|
||||
[13, 102, 201, 'text', 1]
|
||||
]
|
||||
for (const [id, patient, doctor, type, status] of fixtures) add.run(id, patient, doctor, type, status, '2026-09-09', `${String(id).padStart(2, '0')}:00:00`)
|
||||
|
||||
// SQLite's explicit BINARY collation has the same exact text comparison semantics used here.
|
||||
// The remaining statements are the SQL emitted by the real ThinkPHP list/count/tab paths.
|
||||
const execute = statement => db.prepare(statement.replace(/BINARY a\.appointment_type/g, 'a.appointment_type COLLATE BINARY')).all()
|
||||
const result = name => ({
|
||||
ids: execute(sql[name].lists).map(row => row.id),
|
||||
count: execute(sql[name].count)[0].think_count,
|
||||
tabs: Object.fromEntries(execute(sql[name].tabs).map(row => [row.status, row.cnt]))
|
||||
})
|
||||
|
||||
test('video query includes legacy blanks and keeps list, total and status counts aligned', () => {
|
||||
assert.deepEqual(result('video'), { ids: [1, 2, 3, 4, 5], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||
})
|
||||
|
||||
test('text query excludes video, unknown and historical phone records', () => {
|
||||
assert.deepEqual(result('text'), { ids: [6, 7], count: 2, tabs: { 1: 1, 3: 1 } })
|
||||
})
|
||||
|
||||
test('all and omitted filters retain historical types without admitting deleted diagnoses', () => {
|
||||
for (const name of ['omitted', 'all', 'null']) {
|
||||
const actual = result(name)
|
||||
assert.equal(actual.count, 11)
|
||||
assert.deepEqual(actual.ids.toSorted((a, b) => a - b), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
assert.equal(Object.values(actual.tabs).reduce((sum, count) => sum + count, 0), 11)
|
||||
}
|
||||
})
|
||||
|
||||
test('type filter composes with status, doctor and patient filters without OR leakage', () => {
|
||||
assert.deepEqual(result('video_status_1'), { ids: [1, 2], count: 2, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||
assert.deepEqual(result('text_doctor'), { ids: [6], count: 1, tabs: { 1: 1 } })
|
||||
assert.deepEqual(result('video_patient'), { ids: [4, 5], count: 2, tabs: { 3: 1, 4: 1 } })
|
||||
})
|
||||
|
||||
test('pagination limits only visible rows while count and tabs retain the full filtered set', () => {
|
||||
assert.deepEqual(result('video_page'), { ids: [2, 3], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||
})
|
||||
|
||||
test('unsupported filter values return no rows or counts instead of being converted to video', () => {
|
||||
for (const name of Object.keys(sql).filter(name => name.startsWith('invalid_'))) {
|
||||
assert.deepEqual(result(name), { ids: [], count: 0, tabs: {} }, name)
|
||||
}
|
||||
})
|
||||
|
||||
function moduleFrom(source, dependencies = require, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(dependencies, module, module.exports, ...Object.values(globals))
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const pageFile = path.join(root, 'admin/src/views/consumer/prescription/guahao.vue')
|
||||
const { descriptor, errors } = parse(fs.readFileSync(pageFile, 'utf8'), { filename: pageFile })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: 'appointment-type-filter' })
|
||||
|
||||
test('actual page and paging hook send type on query and clear it together with all fields on reset', async () => {
|
||||
const calls = []
|
||||
const paging = moduleFrom(fs.readFileSync(path.join(root, 'admin/src/hooks/usePaging.ts'), 'utf8'))
|
||||
const component = moduleFrom(script.content, name => {
|
||||
if (name === 'vue') return vue
|
||||
if (name === '@/hooks/usePaging') return paging
|
||||
if (name === '@/api/doctor') return { appointmentLists: async params => { calls.push({ ...params }); return { lists: [], count: 0 } } }
|
||||
if (name === '@/utils/appointment-type') return { appointmentTypeDescription: value => value }
|
||||
return {}
|
||||
}, { reactive: vue.reactive, ref: vue.ref, computed: vue.computed, onMounted() {}, onActivated() {} })
|
||||
const page = component.default.setup({}, { expose() {} })
|
||||
assert.equal(calls[0].appointment_type, '')
|
||||
page.pager.page = 4
|
||||
page.queryParams.appointment_type = 'text'
|
||||
page.queryParams.patient_name = '张'
|
||||
page.resetPage()
|
||||
assert.equal(calls.at(-1).page_no, 1)
|
||||
assert.equal(calls.at(-1).appointment_type, 'text')
|
||||
assert.equal(calls.at(-1).patient_name, '张')
|
||||
page.pager.page = 3
|
||||
page.queryParams.appointment_type = 'video'
|
||||
page.resetPage()
|
||||
assert.equal(calls.at(-1).appointment_type, 'video')
|
||||
page.resetFilter()
|
||||
assert.equal(calls.at(-1).appointment_type, '')
|
||||
assert.equal(calls.at(-1).patient_name, '')
|
||||
assert.equal(calls.at(-1).page_no, 1)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
test('updated filter template compiles with existing page bindings', () => {
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: pageFile, id: 'appointment-type-filter', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/utils/im-chat-archive-trigger.ts'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function('module', 'exports', compiled)(mod, mod.exports)
|
||||
const { createImChatArchiveTrigger } = mod.exports
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((a, b) => { resolve = a; reject = b })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('all pages use server-owned current-doctor scope and carry the continuation token', async () => {
|
||||
const calls = []
|
||||
const sync = createImChatArchiveTrigger(async params => {
|
||||
calls.push(params)
|
||||
return { completed: calls.length === 3, sync_token: 'next-page' }
|
||||
})
|
||||
await sync(12116)
|
||||
assert.deepEqual(calls, [
|
||||
{ diagnosis_id: 12116, scope: 'current', sync_token: undefined },
|
||||
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' },
|
||||
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' }
|
||||
])
|
||||
})
|
||||
|
||||
test('many events while syncing coalesce, then perform one catch-up scan for new messages', async () => {
|
||||
const page = deferred()
|
||||
let calls = 0
|
||||
const sync = createImChatArchiveTrigger(() => {
|
||||
calls++
|
||||
return calls === 1 ? page.promise : Promise.resolve({ completed: true })
|
||||
})
|
||||
const first = sync(10)
|
||||
assert.equal(sync(10), first)
|
||||
assert.equal(sync(10), first)
|
||||
assert.equal(calls, 1)
|
||||
page.resolve({ completed: true })
|
||||
await first
|
||||
assert.equal(calls, 2)
|
||||
await sync(10)
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('switching patients keeps in-flight requests bound to their original diagnosis', async () => {
|
||||
const old = deferred(), calls = []
|
||||
const sync = createImChatArchiveTrigger(params => {
|
||||
calls.push(params)
|
||||
if (params.diagnosis_id === 10 && !params.sync_token) return old.promise
|
||||
return Promise.resolve({ completed: true })
|
||||
})
|
||||
const first = sync(10)
|
||||
await sync(20)
|
||||
old.resolve({ completed: false, sync_token: 'old-patient' })
|
||||
await first
|
||||
assert.deepEqual(calls.map(p => [p.diagnosis_id, p.sync_token]), [[10, undefined], [20, undefined], [10, 'old-patient']])
|
||||
})
|
||||
|
||||
test('partial failure and network failure are reported and do not poison later retries', async () => {
|
||||
const warnings = []
|
||||
let count = 0
|
||||
const sync = createImChatArchiveTrigger(async () => {
|
||||
count++
|
||||
if (count === 1) return { completed: true, errors: ['cloud unavailable'] }
|
||||
if (count === 2) throw new Error('request timeout')
|
||||
return { completed: true }
|
||||
}, error => warnings.push(error.message))
|
||||
await sync(10)
|
||||
await sync(10)
|
||||
await sync(10)
|
||||
assert.deepEqual(warnings, ['cloud unavailable', 'request timeout'])
|
||||
assert.equal(count, 3)
|
||||
})
|
||||
|
||||
test('invalid IDs never request and missing progress stops a malformed response loop', async () => {
|
||||
const warnings = []
|
||||
let count = 0
|
||||
const sync = createImChatArchiveTrigger(async () => { count++; return { completed: false } }, error => warnings.push(error.message))
|
||||
for (const id of [0, -1, NaN, 1.5]) await sync(id)
|
||||
assert.equal(count, 0)
|
||||
await sync(10)
|
||||
assert.equal(count, 1)
|
||||
assert.match(warnings[0], /未返回进度/)
|
||||
})
|
||||
@@ -0,0 +1,392 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
|
||||
|
||||
function loadTs(filename, dependencies = require) {
|
||||
const source = fs.readFileSync(filename, 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const { createImChatHistory } = loadTs(path.join(__dirname, '../src/utils/im-chat-history.ts'))
|
||||
const archive = (id, text = '已归档消息') => ({ lists: [{ msg_id: id, text }], patient_im_id: `patient_${id}`, patient_name: `患者${id}` })
|
||||
const progress = (completed, extra = {}) => ({ completed, sync_token: 'session-token', inserted: 2, processed_peers: 1, total_peers: 2, ...extra })
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const settle = async () => { for (let i = 0; i < 50; i++) await Promise.resolve() }
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
let time = 100000, nextTimer = 0
|
||||
const timers = new Map(), calls = [], notices = []
|
||||
const controller = createImChatHistory({
|
||||
load: async (id) => { calls.push(['load', id]); return overrides.load ? overrides.load(id) : archive(id) },
|
||||
sync: async (id, token) => { calls.push(['sync', id, token]); return overrides.sync ? overrides.sync(id, token) : progress(true) },
|
||||
notify: (kind, message) => notices.push({ kind, message }),
|
||||
now: () => time,
|
||||
setTimer: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id },
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
visible: overrides.visible
|
||||
})
|
||||
return {
|
||||
controller, state: controller.state, calls, notices, timers,
|
||||
setTime(value) { time = value },
|
||||
advance(ms) {
|
||||
time += ms
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.at <= time) { timers.delete(id); timer.callback() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('archive appears immediately while automatic cloud sync is pending, then refreshes on completion', async () => {
|
||||
const pending = deferred()
|
||||
let reads = 0
|
||||
const f = fixture({ load: async () => archive(++reads), sync: () => pending.promise })
|
||||
f.controller.setDiagnosis(10)
|
||||
await settle()
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
assert.equal(f.state.syncing, true)
|
||||
assert.equal(f.state.loading, false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
pending.resolve(progress(true))
|
||||
await settle()
|
||||
assert.equal(f.state.rows[0].msg_id, 2)
|
||||
assert.equal(f.state.syncing, false)
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.deepEqual(f.notices, [])
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('account verification progress and skipped staff are displayed separately from sync failures', async () => {
|
||||
const checked = deferred(), finished = deferred()
|
||||
let step = 0
|
||||
const f = fixture({ sync: async () => {
|
||||
step++
|
||||
if (step === 1) return progress(false, { phase: 'checking_accounts', checked_accounts: 100, candidate_accounts: 185, skipped_accounts: 98 })
|
||||
if (step === 2) return checked.promise
|
||||
return finished.promise
|
||||
} })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
assert.equal(f.state.phase, 'checking_accounts')
|
||||
assert.equal(f.state.checkedAccounts, 100)
|
||||
assert.equal(f.state.candidateAccounts, 185)
|
||||
assert.deepEqual(f.state.partialErrors, [])
|
||||
checked.resolve(progress(false, { phase: 'syncing', checked_accounts: 185, candidate_accounts: 185, skipped_accounts: 183, total_peers: 1 }))
|
||||
await settle()
|
||||
assert.equal(f.state.totalPeers, 1)
|
||||
assert.equal(f.state.skippedAccounts, 183)
|
||||
finished.resolve(progress(true, { phase: 'completed', skipped_accounts: 183, total_peers: 1 }))
|
||||
await settle()
|
||||
assert.equal(f.state.syncError, '')
|
||||
assert.deepEqual(f.state.partialErrors, [])
|
||||
f.controller.setDiagnosis(2)
|
||||
assert.equal(f.state.skippedAccounts, 0)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('completion waits for initial archive read and starts a fresh read after it', async () => {
|
||||
const initial = deferred()
|
||||
let reads = 0
|
||||
const f = fixture({ load: () => ++reads === 1 ? initial.promise : Promise.resolve(archive(2)) })
|
||||
f.controller.setDiagnosis(10)
|
||||
await settle()
|
||||
assert.equal(reads, 1)
|
||||
initial.resolve(archive(1))
|
||||
await settle()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(f.state.rows[0].msg_id, 2)
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('sync tokens continue across pages and archive refreshes every three pages and on completion', async () => {
|
||||
const lastPage = deferred()
|
||||
let pages = 0, reads = 0
|
||||
const f = fixture({
|
||||
load: async () => archive(++reads),
|
||||
sync: async () => ++pages < 4 ? progress(false, { sync_token: `token-${pages}`, inserted: pages }) : lastPage.promise
|
||||
})
|
||||
f.controller.setDiagnosis(10)
|
||||
await settle()
|
||||
assert.equal(pages, 4)
|
||||
assert.equal(reads, 2)
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'sync').map(call => call[2]), [undefined, 'token-1', 'token-2', 'token-3'])
|
||||
assert.equal(f.state.inserted, 3)
|
||||
lastPage.resolve(progress(true, { inserted: 4, processed_peers: 2 }))
|
||||
await settle()
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(f.state.inserted, 4)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('a slow page refreshes archive after two seconds even before three pages', async () => {
|
||||
const lastPage = deferred()
|
||||
let pages = 0, reads = 0
|
||||
const f = fixture({
|
||||
load: async () => archive(++reads),
|
||||
sync: async () => {
|
||||
if (++pages === 1) { f.setTime(102100); return progress(false) }
|
||||
return lastPage.promise
|
||||
}
|
||||
})
|
||||
f.controller.setDiagnosis(10)
|
||||
await settle()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(f.state.syncing, true)
|
||||
f.controller.dispose()
|
||||
lastPage.resolve(progress(true))
|
||||
await settle()
|
||||
})
|
||||
|
||||
test('switching diagnosis ignores old archive, old sync errors, and old completion timers', async () => {
|
||||
const oldRead = deferred(), oldSync = deferred()
|
||||
const f = fixture({ load: (id) => id === 1 ? oldRead.promise : Promise.resolve(archive(id)), sync: (id) => id === 1 ? oldSync.promise : Promise.resolve(progress(true)) })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
f.controller.setDiagnosis(2)
|
||||
await settle()
|
||||
oldRead.resolve(archive(1))
|
||||
oldSync.reject(new Error('旧患者同步失败'))
|
||||
await settle()
|
||||
assert.equal(f.state.patientName, '患者2')
|
||||
assert.equal(f.state.rows[0].msg_id, 2)
|
||||
assert.equal(f.state.syncError, '')
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.equal(f.calls.filter(call => call[0] === 'load' && call[1] === 1).length, 1)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('dispose invalidates in-flight requests and never refreshes or schedules after completion', async () => {
|
||||
const pending = deferred()
|
||||
const f = fixture({ sync: () => pending.promise })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
const before = f.calls.length
|
||||
f.controller.dispose()
|
||||
pending.resolve(progress(true))
|
||||
await settle()
|
||||
assert.equal(f.calls.length, before)
|
||||
assert.equal(f.timers.size, 0)
|
||||
assert.equal(f.state.syncing, false)
|
||||
f.controller.setVisible(true)
|
||||
f.controller.setDiagnosis(2)
|
||||
assert.equal(f.calls.length, before)
|
||||
})
|
||||
|
||||
test('read and sync failures preserve existing rows and expose the original errors', async () => {
|
||||
let readFails = false, syncFails = false
|
||||
const f = fixture({
|
||||
load: async () => { if (readFails) throw { response: { data: { msg: '归档服务读取失败' } } }; return archive(1) },
|
||||
sync: async () => { if (syncFails) throw new Error('腾讯云凭证无效'); return progress(true) }
|
||||
})
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
readFails = true
|
||||
await f.controller.reload()
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
assert.equal(f.state.readError, '归档服务读取失败')
|
||||
syncFails = true
|
||||
await f.controller.sync()
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
assert.equal(f.state.syncError, '腾讯云凭证无效')
|
||||
assert.deepEqual(f.notices, [{ kind: 'error', message: '腾讯云凭证无效' }])
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('only a manual completed sync emits success; concurrent clicks share the in-flight run', async () => {
|
||||
let pending
|
||||
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
assert.deepEqual(f.notices, [])
|
||||
pending = deferred()
|
||||
const manual = f.controller.sync()
|
||||
const duplicate = f.controller.sync()
|
||||
await settle()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||
assert.deepEqual(f.notices, [])
|
||||
pending.resolve(progress(true, { inserted: 7 }))
|
||||
await Promise.all([manual, duplicate])
|
||||
assert.deepEqual(f.notices, [{ kind: 'success', message: '聊天记录已更新,本次新增 7 条' }])
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('completed partial failure refreshes rows, ends the loop, and never reports full success', async () => {
|
||||
let partial = false, reads = 0
|
||||
const f = fixture({ load: async () => archive(++reads), sync: async () => progress(true, partial ? { error: '医生账号失败', errors: ['医生账号失败', '医助账号失败'] } : {}) })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
partial = true
|
||||
await f.controller.sync()
|
||||
assert.deepEqual([...f.state.partialErrors], ['医生账号失败', '医助账号失败'])
|
||||
assert.equal(f.state.rows[0].msg_id, 3)
|
||||
assert.equal(f.state.syncing, false)
|
||||
assert.equal(f.notices.length, 1)
|
||||
assert.equal(f.notices[0].kind, 'warning')
|
||||
assert.match(f.notices[0].message, /医生账号失败;医助账号失败/)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('missing token and missing completed state stop safely without claiming success', async () => {
|
||||
for (const response of [{ completed: false }, { inserted: 0 }]) {
|
||||
const f = fixture({ sync: async () => response })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
|
||||
assert.match(f.state.syncError, /同步接口未返回/)
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
assert.equal(f.notices.length, 0)
|
||||
f.controller.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('one 30-second timer starts only after completion and never overlaps a pending run', async () => {
|
||||
let pending
|
||||
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
f.advance(29999)
|
||||
await settle()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
|
||||
pending = deferred()
|
||||
f.advance(1)
|
||||
await settle()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.advance(90000)
|
||||
await settle()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||
pending.resolve(progress(true))
|
||||
await settle()
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.controller.dispose()
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('hidden or deactivated panels stop scheduling and resume with existing rows retained', async () => {
|
||||
const f = fixture({ visible: false })
|
||||
f.controller.setDiagnosis(1)
|
||||
await settle()
|
||||
assert.deepEqual(f.calls, [])
|
||||
f.controller.setVisible(true)
|
||||
await settle()
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
f.controller.setVisible(false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
const before = f.calls.length
|
||||
f.advance(60000)
|
||||
await settle()
|
||||
assert.equal(f.calls.length, before)
|
||||
f.controller.setVisible(true)
|
||||
assert.equal(f.state.rows[0].msg_id, 1)
|
||||
await settle()
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.controller.dispose()
|
||||
})
|
||||
|
||||
test('IM API functions retain raw backend reasons, send progress tokens, and disable retries', async () => {
|
||||
const calls = []
|
||||
let reply = { code: 1, data: progress(true), msg: '不应自动显示的成功提示', show: 1 }
|
||||
const request = {}
|
||||
for (const method of ['get', 'post']) request[method] = async (config, options) => { calls.push({ method, config, options }); return reply }
|
||||
const api = loadTs(path.join(__dirname, '../src/api/tcm.ts'), (name) => {
|
||||
if (name === '@/utils/request') return { default: request }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
})
|
||||
assert.equal((await api.triggerImChatSync({ diagnosis_id: 2, sync_token: 'resume' })).completed, true)
|
||||
reply = { code: 1, data: archive(2) }
|
||||
assert.equal((await api.getImChatMessages({ diagnosis_id: 2, only_archived: 1 })).lists[0].msg_id, 2)
|
||||
assert.deepEqual(calls[0].config.data, { diagnosis_id: 2, sync_token: 'resume' })
|
||||
for (const call of calls) {
|
||||
assert.equal(call.config.timeout, 30000)
|
||||
assert.deepEqual(call.options, { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 })
|
||||
}
|
||||
reply = { code: 0, data: [], msg: '腾讯云错误:签名校验失败' }
|
||||
await assert.rejects(api.triggerImChatSync({ diagnosis_id: 2 }), /腾讯云错误:签名校验失败/)
|
||||
})
|
||||
|
||||
test('chat panel script, template, and scoped styles compile', () => {
|
||||
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: 'im-chat-history' })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'im-chat-history', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'im-chat-history', scoped: true, preprocessLang: 'scss' }).errors, [])
|
||||
})
|
||||
|
||||
test('actual panel setup wires archived reads, continued sync, patient switches, and lifecycle cleanup', async () => {
|
||||
const vue = require('vue')
|
||||
const hooks = {}, listeners = new Map(), timers = new Map(), apiCalls = []
|
||||
const pending = deferred()
|
||||
let timerId = 0
|
||||
const originalDocument = global.document
|
||||
global.document = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: (name, callback) => listeners.set(name, callback),
|
||||
removeEventListener: (name) => listeners.delete(name)
|
||||
}
|
||||
const scope = vue.effectScope()
|
||||
try {
|
||||
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
|
||||
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
const script = compileScript(descriptor, { id: 'im-chat-panel-setup' })
|
||||
const compiled = ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)((name) => {
|
||||
if (name === 'vue') return { ...vue, ...Object.fromEntries(['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map(hook => [hook, callback => { hooks[hook] = callback }])) }
|
||||
if (name === 'dayjs') return { default: require('dayjs') }
|
||||
if (name === '@element-plus/icons-vue') return {}
|
||||
if (name === 'element-plus') return { ElMessage: { success() {}, warning() {}, error() {} } }
|
||||
if (name === '@/utils/im-business-message-parse') return { parseImBusinessPayload: () => null }
|
||||
if (name === '@/utils/im-chat-history') return { createImChatHistory: (deps) => createImChatHistory({ ...deps, setTimer: callback => { const id = ++timerId; timers.set(id, callback); return id }, clearTimer: id => timers.delete(id) }) }
|
||||
if (name === '@/api/tcm') return {
|
||||
getImChatMessages: async params => { apiCalls.push(['read', params]); return archive(params.diagnosis_id) },
|
||||
triggerImChatSync: async params => { apiCalls.push(['sync', params]); return params.diagnosis_id === 1 ? pending.promise : progress(true) }
|
||||
}
|
||||
throw new Error(`Unexpected panel import: ${name}`)
|
||||
}, module, module.exports)
|
||||
const props = vue.reactive({ diagnosisId: 1 })
|
||||
const panel = scope.run(() => module.exports.default.setup(props, { expose() {} }))
|
||||
hooks.onMounted()
|
||||
await settle()
|
||||
assert.equal(panel.rows.value[0].raw.msg_id, 1)
|
||||
assert.deepEqual(apiCalls[0], ['read', { diagnosis_id: 1, only_archived: 1 }])
|
||||
assert.deepEqual(apiCalls[1], ['sync', { diagnosis_id: 1 }])
|
||||
props.diagnosisId = 2
|
||||
await settle()
|
||||
assert.equal(panel.rows.value[0].raw.msg_id, 2)
|
||||
pending.resolve(progress(true))
|
||||
await settle()
|
||||
assert.equal(panel.rows.value[0].raw.msg_id, 2)
|
||||
assert.equal(timers.size, 1)
|
||||
panel.history.rows = [{
|
||||
msg_id: 'multi-part', msg_type: 'composite', from_account: 'doctor_20', to_account: 'patient_2', time: 1700000000,
|
||||
parts: [{ msg_type: 'text', text: '第一段' }, { msg_type: 'image', image_url: 'https://example.invalid/chat.png' }, { msg_type: 'text', text: '第三段' }]
|
||||
}]
|
||||
assert.deepEqual(panel.rows.value.map(item => [item.raw.msg_id, item.raw.msg_type]), [
|
||||
['multi-part:0', 'text'], ['multi-part:1', 'image'], ['multi-part:2', 'text']
|
||||
])
|
||||
assert.equal(panel.rows.value[2].raw.text, '第三段')
|
||||
assert.ok(panel.rows.value.every(item => item.raw.to_account === 'patient_2'))
|
||||
global.document.visibilityState = 'hidden'
|
||||
listeners.get('visibilitychange')()
|
||||
assert.equal(timers.size, 0)
|
||||
hooks.onBeforeUnmount()
|
||||
assert.equal(listeners.size, 0)
|
||||
} finally {
|
||||
scope.stop()
|
||||
global.document = originalDocument
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user