更新
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
|
||||
}
|
||||
})
|
||||
@@ -7,5 +7,5 @@ __version__ = "1.4.2"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
DEBUG_MODE = True
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -412,7 +412,8 @@ class ApplicationController(QObject):
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self._pending_im_request: tuple[str, object] | None = None
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
@@ -725,7 +726,8 @@ class ApplicationController(QObject):
|
||||
call.close()
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
self.video_pending.clear()
|
||||
self._pending_im_request = None
|
||||
for dialog in self.demo_video_dialogs.values():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -746,7 +748,8 @@ class ApplicationController(QObject):
|
||||
if parent is None or self.current_repository is None:
|
||||
return
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
appointment_id = int(payload.get("appointment_id") or 0)
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
@@ -754,8 +757,16 @@ class ApplicationController(QObject):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
|
||||
call_key = str(diagnosis_id)
|
||||
existing_call = self.video_calls.get(call_key)
|
||||
call_key = f"{diagnosis_id}:{appointment_id}"
|
||||
if open_im and self._pending_im_request is not None:
|
||||
pending_key, pending_marker = self._pending_im_request
|
||||
if pending_key != call_key:
|
||||
# Retire a previous selection before any early return, including
|
||||
# when the selected IM window is already open.
|
||||
if self.video_pending.get(pending_key) is pending_marker:
|
||||
self.video_pending.pop(pending_key, None)
|
||||
self._pending_im_request = None
|
||||
existing_call = self.video_calls.get(call_key)
|
||||
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
||||
qt_window = getattr(existing_call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
@@ -801,13 +812,18 @@ class ApplicationController(QObject):
|
||||
)
|
||||
repository = self.current_repository
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
self.video_pending[call_key] = marker
|
||||
if open_im:
|
||||
self._pending_im_request = (call_key, marker)
|
||||
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
appointment_id=appointment_id,
|
||||
)
|
||||
if appointment_id and int(ticket.raw.get("appointment_id") or 0) != appointment_id:
|
||||
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
@@ -868,10 +884,12 @@ class ApplicationController(QObject):
|
||||
parent: QWidget,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self.shell_window is parent:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self._pending_im_request == (call_key, marker):
|
||||
self._pending_im_request = None
|
||||
if self.shell_window is parent:
|
||||
show_toast(
|
||||
parent,
|
||||
f"视频准备失败:{friendly_error(error)}",
|
||||
@@ -892,9 +910,11 @@ class ApplicationController(QObject):
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self._pending_im_request == (call_key, marker):
|
||||
self._pending_im_request = None
|
||||
if (
|
||||
self.shell_window is None
|
||||
or self.current_repository is not repository
|
||||
@@ -919,8 +939,8 @@ class ApplicationController(QObject):
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
|
||||
self._open_video_diagnosis(current_id, call_key=current_key)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -942,7 +962,7 @@ class ApplicationController(QObject):
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any, *, call_key: str) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
@@ -951,7 +971,7 @@ class ApplicationController(QObject):
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
call = self.video_calls.get(call_key)
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Appointment medium labels, separate from first-visit/follow-up diagnosis type."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
APPOINTMENT_MODES = (("视频问诊", "video"), ("图文问诊", "text"))
|
||||
|
||||
|
||||
def appointment_type_description(value: Any) -> str:
|
||||
if value is None or isinstance(value, str) and not value.strip():
|
||||
return "视频问诊"
|
||||
return {"video": "视频问诊", "text": "图文问诊", "phone": "电话问诊"}.get(str(value), "未知")
|
||||
|
||||
|
||||
def can_appointment_video(value: Any) -> bool:
|
||||
"""UI hint only; actual call permission must come from the server ticket."""
|
||||
return value is None or isinstance(value, str) and (not value.strip() or value == "video")
|
||||
|
||||
|
||||
def appointment_type_value(record: Any) -> Any:
|
||||
"""Read appointment rows and diagnosis-list projections without visit-type aliases."""
|
||||
def read(value: Any, key: str) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return value.get(key)
|
||||
result = getattr(value, key, None)
|
||||
raw = getattr(value, "raw", None)
|
||||
return raw.get(key) if result is None and isinstance(raw, Mapping) else result
|
||||
|
||||
appointment_id = read(record, "appointment_id") or read(record, "latest_appointment_id")
|
||||
rows = read(record, "appointments")
|
||||
if isinstance(rows, (list, tuple)) and rows:
|
||||
if appointment_id:
|
||||
for row in rows:
|
||||
if str(read(row, "id")) == str(appointment_id):
|
||||
return read(row, "appointment_type")
|
||||
elif len(rows) == 1:
|
||||
return read(rows[0], "appointment_type")
|
||||
else:
|
||||
return "unknown"
|
||||
for key in ("appointment_type", "latest_appointment_type"):
|
||||
value = read(record, key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
for key in ("appointment", "latest_appointment"):
|
||||
nested = read(record, key)
|
||||
if nested is not None:
|
||||
return read(nested, "appointment_type")
|
||||
return None
|
||||
@@ -3022,7 +3022,7 @@ class DemoDoctorRepository:
|
||||
raise ValueError("order_no is required")
|
||||
return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"}
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Return non-production placeholder credentials for UI demonstration."""
|
||||
|
||||
with self._lock:
|
||||
@@ -3036,21 +3036,22 @@ class DemoDoctorRepository:
|
||||
assistant_id="assistant_2001",
|
||||
diagnosis_id=diagnosis_id,
|
||||
is_lochost_vod=False,
|
||||
raw={"demo": True},
|
||||
raw={"demo": True, "appointment_id": appointment_id},
|
||||
)
|
||||
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int = 2
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0
|
||||
) -> dict[str, Any]:
|
||||
"""Create a mutable demo call record."""
|
||||
|
||||
with self._lock:
|
||||
self.get_call_ticket(patient_id, diagnosis_id)
|
||||
self.get_call_ticket(patient_id, diagnosis_id, appointment_id=appointment_id)
|
||||
record = {
|
||||
"id": self._next_call_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
"call_type": call_type,
|
||||
"appointment_id": appointment_id,
|
||||
"status": "ringing",
|
||||
"room_id": "",
|
||||
}
|
||||
|
||||
@@ -712,10 +712,10 @@ class DoctorRepository(Protocol):
|
||||
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
|
||||
"""Generate the payment mini-program QR code for a generic order."""
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Return short-lived call credentials."""
|
||||
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
|
||||
"""Create a call record."""
|
||||
|
||||
def end_call(
|
||||
@@ -2629,22 +2629,51 @@ class RemoteDoctorRepository:
|
||||
)
|
||||
return result
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Obtain short-lived Tencent credentials for a consultation call."""
|
||||
|
||||
result = _require_mapping(
|
||||
self.client.post(
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
{"patient_id": patient_id, "diagnosis_id": diagnosis_id},
|
||||
result = _require_mapping(
|
||||
self.client.post(
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
{"patient_id": patient_id, "diagnosis_id": diagnosis_id, "appointment_id": appointment_id},
|
||||
),
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
)
|
||||
ticket = CallTicket.from_dict(result)
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
)
|
||||
policy_fields = {
|
||||
"appointment_id", "appointmentId", "appointment_type", "appointment_type_desc",
|
||||
"can_video_call", "can_audio_call", "call_disabled_reason",
|
||||
}
|
||||
if appointment_id > 0 and not policy_fields.intersection(result):
|
||||
# Older signature endpoints authenticate the patient/diagnosis but
|
||||
# do not echo the appointment. Keep the requested chat context;
|
||||
# never treat it as server authorization for an audio/video call.
|
||||
if (
|
||||
str(result.get("diagnosis_id", "")).strip() != str(diagnosis_id)
|
||||
or str(result.get("patient_id", "")).strip() != str(patient_id)
|
||||
or result.get("patientUserId") != f"patient_{patient_id}"
|
||||
):
|
||||
raise ValueError("聊天凭证与当前患者或诊单不匹配,请重新打开聊天窗口。")
|
||||
result = dict(result)
|
||||
result.update(
|
||||
appointment_id=appointment_id,
|
||||
can_video_call=False,
|
||||
can_audio_call=False,
|
||||
call_disabled_reason="暂未获取到本次挂号的通话权限,仍可图文聊天",
|
||||
)
|
||||
if appointment_id > 0:
|
||||
returned_id = result.get("appointment_id")
|
||||
if (
|
||||
isinstance(returned_id, bool)
|
||||
or not isinstance(returned_id, (int, str))
|
||||
or str(returned_id).strip() != str(appointment_id)
|
||||
):
|
||||
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
|
||||
ticket = CallTicket.from_dict(result)
|
||||
if ticket.diagnosis_id is None:
|
||||
ticket.diagnosis_id = diagnosis_id
|
||||
return ticket
|
||||
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
|
||||
"""Create the server-side call record before ringing participants."""
|
||||
|
||||
payload = self.client.post(
|
||||
@@ -2652,7 +2681,8 @@ class RemoteDoctorRepository:
|
||||
{
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
"call_type": call_type,
|
||||
"appointment_id": appointment_id,
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
|
||||
@@ -42,7 +42,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .widgets import (
|
||||
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||
from .widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
friendly_error,
|
||||
@@ -972,13 +973,24 @@ class AppointmentDrawer(QDialog):
|
||||
type_container = QWidget()
|
||||
type_layout = QHBoxLayout(type_container)
|
||||
type_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.appointment_type_radio = QRadioButton("视频问诊")
|
||||
self.appointment_type_radio.setChecked(True)
|
||||
type_layout.addWidget(self.appointment_type_radio)
|
||||
self.appointment_type_radio = QRadioButton("视频问诊")
|
||||
self.appointment_type_radio.setChecked(True)
|
||||
type_layout.addWidget(self.appointment_type_radio)
|
||||
self.text_appointment_type_radio = QRadioButton("图文问诊")
|
||||
type_layout.addWidget(self.text_appointment_type_radio)
|
||||
self.appointment_type_group = QButtonGroup(self)
|
||||
self.appointment_type_group.addButton(self.appointment_type_radio, 0)
|
||||
self.appointment_type_group.addButton(self.text_appointment_type_radio, 1)
|
||||
type_layout.addStretch(1)
|
||||
self._add_form_row("预约类型:", type_container)
|
||||
self.appointment_type = QComboBox(self)
|
||||
self.appointment_type.addItem("视频问诊", "video")
|
||||
for label, value in APPOINTMENT_MODES:
|
||||
self.appointment_type.addItem(label, value)
|
||||
self.appointment_type_group.idClicked.connect(self.appointment_type.setCurrentIndex)
|
||||
self.appointment_type.currentIndexChanged.connect(
|
||||
lambda index: self.appointment_type_group.button(index).setChecked(True)
|
||||
if self.appointment_type_group.button(index) is not None else None
|
||||
)
|
||||
self.appointment_type.hide()
|
||||
|
||||
patient_container = QWidget()
|
||||
|
||||
@@ -26,9 +26,9 @@ from PySide6.QtCore import (
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QIcon,
|
||||
QLinearGradient,
|
||||
QMouseEvent,
|
||||
@@ -61,8 +61,13 @@ from PySide6.QtWidgets import (
|
||||
QWidgetItem,
|
||||
)
|
||||
|
||||
from . import icons
|
||||
from .reception_style import TECH_BLUE, body_family, heading_family
|
||||
from ..core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from . import icons
|
||||
from .reception_style import TECH_BLUE, body_family, heading_family
|
||||
from .widgets import display_text, first_value, gender_text, get_value
|
||||
|
||||
PRIMARY = QColor("#4F63D9")
|
||||
@@ -112,7 +117,7 @@ def _blue_appointment_text(record: Any, appointment: Any) -> tuple[str, str]:
|
||||
This returns display strings without mutating either repository record.
|
||||
"""
|
||||
|
||||
current_id = _as_int(first_value(record, "appointment_id", default=0))
|
||||
current_id = _as_int(first_value(record, "appointment_id", "latest_appointment_id", default=0))
|
||||
appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0))
|
||||
current = current_id > 0 and appointment_id == current_id
|
||||
doctor = first_value(appointment, "doctor_name", "appointment_doctor_name")
|
||||
@@ -130,6 +135,11 @@ def _blue_appointment_text(record: Any, appointment: Any) -> tuple[str, str]:
|
||||
when = time_text if date_text and date_text in time_text else " ".join(
|
||||
part for part in (date_text, time_text) if part
|
||||
)
|
||||
mode = appointment_type_value(appointment)
|
||||
if current and get_value(appointment, "appointment_type", None) is None:
|
||||
mode = appointment_type_value(record)
|
||||
if appointment_id > 0:
|
||||
when = f"{when} · {appointment_type_description(mode)}"
|
||||
return display_text(doctor, "—"), when
|
||||
|
||||
|
||||
@@ -297,7 +307,8 @@ def _appointments(record: Any) -> list[Any]:
|
||||
return [
|
||||
{
|
||||
"id": first_value(record, "appointment_id"),
|
||||
"status": first_value(record, "appointment_status"),
|
||||
"status": first_value(record, "appointment_status"),
|
||||
"appointment_type": appointment_type_value(record),
|
||||
"doctor_name": first_value(
|
||||
record, "appointment_doctor_name", "doctor_name", default=""
|
||||
),
|
||||
@@ -392,7 +403,8 @@ def _video_ids_complete(record: Any) -> bool:
|
||||
appointment_id = _as_int(
|
||||
first_value(
|
||||
record,
|
||||
"appointment_id",
|
||||
"appointment_id",
|
||||
"latest_appointment_id",
|
||||
default=first_value(
|
||||
_appointments(record)[0] if _appointments(record) else None,
|
||||
"id",
|
||||
@@ -770,7 +782,8 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
part
|
||||
for part in (
|
||||
display_text(first_value(apt, "doctor_name"), "-"),
|
||||
display_text(first_value(apt, "time_text", "appointment_time"), "-"),
|
||||
display_text(first_value(apt, "time_text", "appointment_time"), "-"),
|
||||
appointment_type_description(appointment_type_value(apt)),
|
||||
)
|
||||
if part
|
||||
)
|
||||
@@ -1776,12 +1789,14 @@ class DiagnosisTableHost(QFrame):
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
call_state = video_call_state(record)
|
||||
if video_capable and _appointment_active(record) and call_state == "live":
|
||||
button = QToolButton(host)
|
||||
button.setText("进入视频问诊")
|
||||
is_text = appointment_type_value(record) == "text"
|
||||
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
button = QToolButton(host)
|
||||
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setToolTip("发送文字、图片和文件;不支持音视频通话" if is_text
|
||||
else "医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setEnabled(_video_ids_complete(record))
|
||||
if not button.isEnabled():
|
||||
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
||||
@@ -1900,7 +1915,8 @@ class DiagnosisTableHost(QFrame):
|
||||
"cancel_assign", item
|
||||
),
|
||||
)
|
||||
if self.action_policy.get("video_qr", False) and _appointment_active(record):
|
||||
if (self.action_policy.get("video_qr", False) and _appointment_active(record)
|
||||
and can_appointment_video(appointment_type_value(record))):
|
||||
_add_menu_action(
|
||||
menu,
|
||||
"视频二维码",
|
||||
|
||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||
from ..diagnosis_drawer import (
|
||||
DIAGNOSIS_QSS,
|
||||
CaseGrid,
|
||||
@@ -3589,7 +3590,7 @@ class DiagnosisDialog(QDialog):
|
||||
first_value(row, "assistant_name"),
|
||||
first_value(row, "appointment_date"),
|
||||
first_value(row, "appointment_time", "period"),
|
||||
first_value(row, "appointment_type_text", "appointment_type"),
|
||||
appointment_type_description(appointment_type_value(row)),
|
||||
" / ".join(
|
||||
part
|
||||
for part in (
|
||||
|
||||
@@ -10,7 +10,7 @@ from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
|
||||
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
@@ -36,8 +36,13 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..appointments_style import appointments_stylesheet
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ...core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from ..appointments_style import appointments_stylesheet
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
||||
from ..dialogs.prescription import (
|
||||
PrescriptionDetailDialog,
|
||||
@@ -49,11 +54,11 @@ from ..dialogs.prescription_ai import (
|
||||
can_open_diagnosis_ai_report,
|
||||
present_diagnosis_ai_report,
|
||||
)
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..icons import icon
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..reception_style import heading_family
|
||||
from ..theme import mark_business_dialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..icons import icon
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..reception_style import heading_family
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
MessageBanner,
|
||||
PageHeader,
|
||||
@@ -312,7 +317,8 @@ def _appointment_info_cell(_value: Any, row: Any) -> str:
|
||||
channel = display_text(
|
||||
first_value(row, "channel_name", "channel_source_name", "source_name"), "—"
|
||||
)
|
||||
return f"{status} {doctor}\n{date_text} {time_text}\n最近渠道:{channel}"
|
||||
mode = appointment_type_description(appointment_type_value(row))
|
||||
return f"{status} {doctor}\n{date_text} {time_text} · {mode}\n最近渠道:{channel}"
|
||||
|
||||
|
||||
def _revisit_cell(_value: Any, row: Any) -> str:
|
||||
@@ -1273,7 +1279,16 @@ class AppointmentsPage(QWidget):
|
||||
host,
|
||||
)
|
||||
date_label.setProperty("tableAppointmentMeta", True)
|
||||
layout.addWidget(date_label)
|
||||
date_row = QHBoxLayout()
|
||||
date_row.setContentsMargins(0, 0, 0, 0)
|
||||
date_row.setSpacing(6)
|
||||
date_row.addWidget(date_label)
|
||||
mode_label = QLabel(appointment_type_description(appointment_type_value(row)), host)
|
||||
mode_label.setObjectName("AppointmentModeLabel")
|
||||
mode_label.setProperty("tableAppointmentMeta", True)
|
||||
date_row.addWidget(mode_label)
|
||||
date_row.addStretch(1)
|
||||
layout.addLayout(date_row)
|
||||
channel = display_text(
|
||||
first_value(row, "channel_name", "channel_source_name", "source_name"),
|
||||
"—",
|
||||
@@ -1310,7 +1325,8 @@ class AppointmentsPage(QWidget):
|
||||
layout.setContentsMargins(5, 0, 5, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
button = QPushButton("IM 问诊", host)
|
||||
is_text = appointment_type_value(row) == "text"
|
||||
button = QPushButton("图文沟通" if is_text else "IM 问诊", host)
|
||||
button.setObjectName("AppointmentImConsultButton")
|
||||
button.setProperty("appointmentImAction", True)
|
||||
patient_name = display_text(first_value(row, "patient_name"), "患者")
|
||||
@@ -1338,7 +1354,8 @@ class AppointmentsPage(QWidget):
|
||||
elif status_error:
|
||||
tooltip = status_error
|
||||
else:
|
||||
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
|
||||
tooltip = ("打开图文问诊,可发送文字、图片和文件;不支持音视频通话"
|
||||
if is_text else "打开患者 IM,可发送消息;通话能力由本次挂号决定")
|
||||
button.setToolTip(tooltip)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, source=row: self._run_video_row_action(
|
||||
@@ -1425,8 +1442,9 @@ class AppointmentsPage(QWidget):
|
||||
status = _status_value(row) if has_row else 0
|
||||
not_completed = has_row and status != 3
|
||||
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.qr_button.setEnabled(
|
||||
not_completed
|
||||
self.qr_button.setEnabled(
|
||||
not_completed
|
||||
and can_appointment_video(appointment_type_value(row))
|
||||
and _video_patient_id(row) > 0
|
||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||
)
|
||||
@@ -1448,8 +1466,9 @@ class AppointmentsPage(QWidget):
|
||||
)
|
||||
self.cancel_button.setEnabled(has_row and status == 1)
|
||||
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.toolbar_qr_button.setEnabled(
|
||||
not_completed
|
||||
self.toolbar_qr_button.setEnabled(
|
||||
not_completed
|
||||
and can_appointment_video(appointment_type_value(row))
|
||||
and _video_patient_id(row) > 0
|
||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||
)
|
||||
@@ -1537,7 +1556,8 @@ class AppointmentsPage(QWidget):
|
||||
self.video_requested.emit(
|
||||
{
|
||||
"source": "appointments",
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_type": appointment_type_value(row),
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_name": first_value(row, "patient_name", default="患者"),
|
||||
@@ -1559,9 +1579,12 @@ class AppointmentsPage(QWidget):
|
||||
if not all(callable(getattr(self.repository, name, None)) for name in required_methods):
|
||||
show_toast(self, "当前仓库未提供视频二维码能力。", "warning")
|
||||
return
|
||||
row = self._current_row()
|
||||
if row is None or _status_value(row) == 3:
|
||||
return
|
||||
row = self._current_row()
|
||||
if row is None or _status_value(row) == 3:
|
||||
return
|
||||
if not can_appointment_video(appointment_type_value(row)):
|
||||
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
|
||||
return
|
||||
diagnosis_id = _diagnosis_id(row)
|
||||
patient_id = _video_patient_id(row)
|
||||
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
||||
@@ -2214,7 +2237,7 @@ class AppointmentsPage(QWidget):
|
||||
("时段", period_text),
|
||||
(
|
||||
"预约类型",
|
||||
first_value(detail, "appointment_type_desc", "appointment_type_text"),
|
||||
appointment_type_description(appointment_type_value(detail)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -38,7 +38,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import icons
|
||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||
from .. import icons
|
||||
from ..consultations_style import consultations_stylesheet
|
||||
from ..diagnosis_index_widgets import (
|
||||
DiagnosisChip,
|
||||
@@ -226,7 +227,7 @@ def _appointment_status(record: Any) -> Any:
|
||||
|
||||
|
||||
def _appointment_id(record: Any) -> int:
|
||||
value = first_value(record, "appointment_id", default=None)
|
||||
value = first_value(record, "appointment_id", "latest_appointment_id", default=None)
|
||||
if value is None:
|
||||
value = first_value(get_value(record, "latest_appointment", None), "id", default=None)
|
||||
if value is None:
|
||||
@@ -289,7 +290,8 @@ def _video_payload(record: Any) -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"source": "consultations",
|
||||
"appointment_id": _appointment_id(record),
|
||||
"appointment_id": _appointment_id(record),
|
||||
"appointment_type": appointment_type_value(record),
|
||||
"patient_id": first_value(record, "patient_id", "source_patient_id"),
|
||||
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
|
||||
"patient_name": first_value(record, "patient_name", default="患者"),
|
||||
@@ -2655,7 +2657,11 @@ class ConsultationsPage(QWidget):
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _request_video_qr(self) -> None:
|
||||
def _request_video_qr(self) -> None:
|
||||
record = self.table.current_data()
|
||||
if record is not None and not can_appointment_video(appointment_type_value(record)):
|
||||
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
|
||||
return
|
||||
self._request_qr(
|
||||
capability="video_qr",
|
||||
permission="tcm.diagnosis/videoQr",
|
||||
@@ -3050,11 +3056,11 @@ class ConsultationsPage(QWidget):
|
||||
self.video_button.setEnabled(
|
||||
has_record
|
||||
and is_video_available(record)
|
||||
and video_call_is_live(record)
|
||||
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
||||
and valid_ids
|
||||
)
|
||||
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
|
||||
self.video_qr_toolbar_button.setEnabled(has_record)
|
||||
self.video_qr_toolbar_button.setEnabled(has_record and can_appointment_video(appointment_type_value(record)))
|
||||
self.complete_toolbar_button.setEnabled(has_record)
|
||||
self.case_toolbar_button.setEnabled(has_record)
|
||||
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
|
||||
@@ -3498,7 +3504,7 @@ class ConsultationsPage(QWidget):
|
||||
if record is None or not is_video_available(record):
|
||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||
return
|
||||
if not video_call_is_live(record):
|
||||
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
||||
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||
return
|
||||
payload = _video_payload(record)
|
||||
|
||||
@@ -43,13 +43,18 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import (
|
||||
APPOINTMENT_MODES,
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
)
|
||||
from .. import icons
|
||||
from ..appointment_drawer import AppointmentDrawer
|
||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||
from ..dialogs.ai_consult import can_open_ai_consult
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..patient_orders_style import patient_orders_stylesheet
|
||||
from ..patient_progress_style import patient_progress_stylesheet
|
||||
from ..patients_style import patient_list_stylesheet, patients_chrome_stylesheet
|
||||
@@ -499,7 +504,8 @@ class _LegacyAppointmentDialog(QDialog):
|
||||
form = QFormLayout()
|
||||
form.setVerticalSpacing(10)
|
||||
self.appointment_type = QComboBox()
|
||||
self.appointment_type.addItem("视频问诊", "video")
|
||||
for label, value in APPOINTMENT_MODES:
|
||||
self.appointment_type.addItem(label, value)
|
||||
form.addRow("预约类型 *", self.appointment_type)
|
||||
self.channel_source = QComboBox()
|
||||
self.channel_source.addItem("正在加载渠道…", "")
|
||||
@@ -1367,12 +1373,14 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
painter.setBrush(QColor(fill))
|
||||
painter.drawRoundedRect(badge, 3, 3)
|
||||
draw(status, badge.toRect(), color, True)
|
||||
elif index.column() == 4:
|
||||
value = display_text(first_value(row, "appointment_time_text"))
|
||||
date_text, separator, time_text = value.partition(" ")
|
||||
if separator and time_text:
|
||||
draw(date_text, top)
|
||||
draw(time_text, bottom)
|
||||
elif index.column() == 4:
|
||||
value = display_text(first_value(row, "appointment_time_text"))
|
||||
date_text, separator, time_text = value.partition(" ")
|
||||
if separator and time_text:
|
||||
draw(date_text, top)
|
||||
has_appointment = _as_int(first_value(row, "appointment_id", default=0)) > 0
|
||||
mode = appointment_type_description(appointment_type_value(row)) if has_appointment else ""
|
||||
draw(f"{time_text} · {mode}" if mode else time_text, bottom)
|
||||
else:
|
||||
draw(value, rect)
|
||||
else:
|
||||
@@ -3005,7 +3013,7 @@ class PatientProgressWorkspace(QWidget):
|
||||
"appointment_time",
|
||||
"预约时间",
|
||||
95,
|
||||
lambda value, _row: display_text(value)[:5],
|
||||
lambda value, row: f"{display_text(value)[:5]} · {appointment_type_description(appointment_type_value(row))}",
|
||||
),
|
||||
TableColumn(
|
||||
"ahead_count",
|
||||
|
||||
@@ -73,6 +73,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||
from .. import icons
|
||||
from ..diagnosis_drawer import DailyRecordPanel
|
||||
from ..diagnosis_editors import FlowLayout
|
||||
@@ -7824,6 +7825,7 @@ class ReceptionPage(QWidget):
|
||||
meta_parts = [f"{gender} · {display_text(age)}岁", phone]
|
||||
if visit_id not in (None, ""):
|
||||
meta_parts.append(f"就诊号:{display_text(visit_id)}")
|
||||
meta_parts.append(appointment_type_description(appointment_type_value(appointment)))
|
||||
self.patient_meta_label.setText(" | ".join(meta_parts))
|
||||
self.patient_meta_label.setToolTip(" · ".join(condition_parts))
|
||||
status_number = (
|
||||
@@ -7866,15 +7868,7 @@ class ReceptionPage(QWidget):
|
||||
display_text(first_value(appointment, "assistant_name"))
|
||||
)
|
||||
self.appointment_labels["type"].setText(
|
||||
display_text(
|
||||
first_value(
|
||||
appointment,
|
||||
"appointment_type_text",
|
||||
"type_text",
|
||||
"appointment_type",
|
||||
"type",
|
||||
)
|
||||
)
|
||||
appointment_type_description(appointment_type_value(appointment))
|
||||
)
|
||||
self.appointment_labels["channel"].setText(
|
||||
display_text(
|
||||
@@ -8928,6 +8922,12 @@ class ReceptionPage(QWidget):
|
||||
self.video_button.setEnabled(
|
||||
appointment_id is not None and diagnosis_id is not None and patient_id is not None
|
||||
)
|
||||
is_text = appointment_type_value(appointment) == "text"
|
||||
self.video_button.setText("图文沟通" if is_text else "IM 问诊")
|
||||
self.video_button.setToolTip(
|
||||
"发送文字、图片和文件;图文问诊不支持音视频通话" if is_text
|
||||
else "打开患者 IM,会话通话能力由本次挂号决定"
|
||||
)
|
||||
self.edit_button.setEnabled(diagnosis_id is not None)
|
||||
report_enabled = (
|
||||
self._can_ai_report
|
||||
@@ -9684,6 +9684,7 @@ class ReceptionPage(QWidget):
|
||||
payload = {
|
||||
"source": "reception",
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_type": appointment_type_value(appointment),
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_name": first_value(
|
||||
|
||||
@@ -85,6 +85,18 @@ def _normalized_key(key: Any) -> str:
|
||||
return "".join(character for character in str(key).lower() if character.isalnum())
|
||||
|
||||
|
||||
def _appointment_id(value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise VideoTicketError("appointment_id must be a nonnegative integer") from exc
|
||||
if isinstance(value, bool) or parsed < 0 or str(value) != str(parsed):
|
||||
raise VideoTicketError("appointment_id must be a nonnegative integer")
|
||||
return parsed
|
||||
|
||||
|
||||
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
|
||||
|
||||
|
||||
@@ -138,7 +150,8 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
|
||||
if hasattr(ticket, attribute_name)
|
||||
}
|
||||
if adapted:
|
||||
return adapted
|
||||
# Preserve server policy and identities; model attributes alone omit them.
|
||||
return {**(dict(raw) if isinstance(raw, Mapping) else {}), **adapted}
|
||||
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
|
||||
|
||||
|
||||
@@ -199,8 +212,15 @@ class VideoCallRequest:
|
||||
patient_id: Identifier | None = None
|
||||
call_record_id: Identifier | None = None
|
||||
backend_mode: BackendMode = BackendMode.EMBEDDED
|
||||
appointment_id: int = 0
|
||||
appointment_type: Any = None
|
||||
appointment_type_desc: str = ""
|
||||
can_video_call: bool = False
|
||||
can_audio_call: bool = False
|
||||
call_disabled_reason: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "appointment_id", _appointment_id(self.appointment_id))
|
||||
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
|
||||
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
|
||||
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
|
||||
@@ -257,6 +277,13 @@ class VideoCallRequest:
|
||||
"userSig": self.user_sig,
|
||||
"targetUserId": self.target_user_id,
|
||||
"diagnosisId": self.diagnosis_id,
|
||||
"patientId": self.patient_id,
|
||||
"appointmentId": self.appointment_id,
|
||||
"appointment_type": self.appointment_type,
|
||||
"appointment_type_desc": self.appointment_type_desc,
|
||||
"can_video_call": self.can_video_call is True,
|
||||
"can_audio_call": self.can_audio_call is True,
|
||||
"call_disabled_reason": self.call_disabled_reason,
|
||||
}
|
||||
|
||||
def safe_log_context(self) -> dict[str, Any]:
|
||||
@@ -340,6 +367,12 @@ def normalize_backend_ticket(
|
||||
diagnosis_id=normalized_diagnosis,
|
||||
patient_id=normalized_patient,
|
||||
call_record_id=payload_call_record,
|
||||
appointment_id=_appointment_id(payload.get("appointment_id", 0)),
|
||||
appointment_type=payload.get("appointment_type"),
|
||||
appointment_type_desc=str(payload.get("appointment_type_desc") or ""),
|
||||
can_video_call=payload.get("can_video_call") is True,
|
||||
can_audio_call=payload.get("can_audio_call") is True,
|
||||
call_disabled_reason=str(payload.get("call_disabled_reason") or ""),
|
||||
backend_mode=BackendMode.parse(backend_mode),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from .launcher import VideoCallRequest
|
||||
from .launcher import VideoCallRequest, normalize_backend_ticket
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
@@ -288,7 +288,29 @@ class OrderedCallLifecycle:
|
||||
with self._lock:
|
||||
return self.bound_room_id or self._claimed_room_id
|
||||
|
||||
def start(self) -> Future[bool]:
|
||||
def refresh_call_policy(self) -> Future[dict[str, Any]]:
|
||||
"""Fetch policy for this exact immutable consultation on the worker."""
|
||||
def operation() -> dict[str, Any]:
|
||||
ticket = self.repository.get_call_ticket(
|
||||
patient_id=self.request.patient_id,
|
||||
diagnosis_id=self.request.diagnosis_id,
|
||||
appointment_id=self.request.appointment_id,
|
||||
)
|
||||
refreshed = normalize_backend_ticket(
|
||||
ticket, patient_id=self.request.patient_id,
|
||||
diagnosis_id=self.request.diagnosis_id,
|
||||
)
|
||||
if (refreshed.appointment_id != self.request.appointment_id
|
||||
or refreshed.target_user_id != self.request.target_user_id
|
||||
or refreshed.user_id != self.request.user_id
|
||||
or refreshed.sdk_app_id != self.request.sdk_app_id):
|
||||
raise ValueError("当前问诊已变更,请重新打开聊天窗口")
|
||||
return refreshed.to_web_config()
|
||||
return self._worker.submit("refresh_policy", operation)
|
||||
|
||||
def start(self, *, call_type: int = 2) -> Future[bool]:
|
||||
if type(call_type) is not int or call_type not in (1, 2):
|
||||
raise ValueError("call_type must be 1 or 2")
|
||||
with self._lock:
|
||||
if self._start_future is not None:
|
||||
return self._start_future
|
||||
@@ -297,7 +319,8 @@ class OrderedCallLifecycle:
|
||||
raise ValueError("video repository does not implement start_call")
|
||||
payload: dict[str, Any] = {
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
"call_type": 2,
|
||||
"call_type": call_type,
|
||||
"appointment_id": self.request.appointment_id,
|
||||
}
|
||||
if self.request.patient_id is not None:
|
||||
payload["patient_id"] = self.request.patient_id
|
||||
|
||||
@@ -279,6 +279,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
call_ended = Signal(str) # type: ignore[misc]
|
||||
call_error = Signal(str) # type: ignore[misc]
|
||||
_start_completed = Signal(bool) # type: ignore[misc]
|
||||
_policy_completed = Signal(str, object) # type: ignore[misc]
|
||||
_room_completed = Signal(str, bool, str) # type: ignore[misc]
|
||||
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
||||
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
|
||||
@@ -369,6 +370,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._connect_permissions()
|
||||
|
||||
self._start_completed.connect(self._on_lifecycle_started)
|
||||
self._policy_completed.connect(self._on_policy_completed)
|
||||
self._room_completed.connect(self._on_room_completed)
|
||||
self._screenshot_completed.connect(self._on_screenshot_completed)
|
||||
self._transcription_completed.connect(self._on_transcription_completed)
|
||||
@@ -509,7 +511,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
QTimer.singleShot(0, self._open_diagnosis_safely)
|
||||
return
|
||||
if event == "call-start-request":
|
||||
self._start_call_cycle()
|
||||
self._start_call_cycle(call_type=message.get("callType", 2))
|
||||
return
|
||||
if event == "call-policy-request":
|
||||
self._refresh_call_policy(str(message.get("requestId") or ""))
|
||||
return
|
||||
if event == "screenshot":
|
||||
self._save_screenshot(str(message.get("dataUrl") or ""))
|
||||
@@ -934,16 +939,48 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
"本次本地录音未正常结束,文件已保留以便排查。",
|
||||
)
|
||||
|
||||
def _start_call_cycle(self) -> None:
|
||||
if self._closing or self._start_requested:
|
||||
return
|
||||
def _prepare_call_cycle(self) -> None:
|
||||
if self._call_cycle_closed:
|
||||
self.lifecycle = self._lifecycle_factory()
|
||||
self._lifecycles.append(self.lifecycle)
|
||||
self._call_cycle_closed = False
|
||||
|
||||
def _refresh_call_policy(self, request_id: str) -> None:
|
||||
if not request_id or self._closing or self._shutdown_requested:
|
||||
return
|
||||
self._prepare_call_cycle()
|
||||
def completed(future: Future[dict[str, Any]]) -> None:
|
||||
try:
|
||||
policy = future.result()
|
||||
except Exception:
|
||||
policy = {"call_disabled_reason": "无法确认本次挂号通话权限,请重新打开聊天窗口"}
|
||||
with suppress(RuntimeError):
|
||||
self._policy_completed.emit(request_id, policy)
|
||||
try:
|
||||
self.lifecycle.refresh_call_policy().add_done_callback(completed)
|
||||
except Exception:
|
||||
self._on_policy_completed(request_id, {})
|
||||
|
||||
def _on_policy_completed(self, request_id: str, policy: dict[str, Any]) -> None:
|
||||
if self._closing or self._shutdown_requested or self._released:
|
||||
return
|
||||
# Send policy only; refreshed credentials never enter logs or notices.
|
||||
payload = {key: policy.get(key) for key in (
|
||||
"appointmentId", "appointment_type", "can_video_call", "can_audio_call",
|
||||
"call_disabled_reason", "appointment_type_desc",
|
||||
)}
|
||||
self._page.runJavaScript(
|
||||
"window.doctorConsultation?.callPolicyResult?.("
|
||||
+ json.dumps(request_id) + "," + json.dumps(payload, ensure_ascii=True) + ");"
|
||||
)
|
||||
|
||||
def _start_call_cycle(self, *, call_type: int = 2) -> None:
|
||||
if self._closing or self._shutdown_requested or self._start_requested:
|
||||
return
|
||||
self._prepare_call_cycle()
|
||||
self._start_requested = True
|
||||
try:
|
||||
future = self.lifecycle.start()
|
||||
future = self.lifecycle.start(call_type=call_type)
|
||||
except Exception:
|
||||
self._start_requested = False
|
||||
self._on_lifecycle_started(False)
|
||||
@@ -1149,8 +1186,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._closing = True
|
||||
self._media_active = False
|
||||
self._shutdown_timer.stop()
|
||||
if self._start_requested and not self._call_cycle_closed:
|
||||
self.lifecycle.end(self._close_reason)
|
||||
# Also retire policy-only workers for IM sessions without a live call.
|
||||
self.lifecycle.end(self._close_reason)
|
||||
self._abort_local_audio_recording("")
|
||||
self._release_webengine()
|
||||
|
||||
|
||||
@@ -278,8 +278,10 @@ def test_field_order_density_and_conditional_channel_row(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||
def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
application: QApplication,
|
||||
mode: str,
|
||||
) -> None:
|
||||
repository = _VisualRepository(today_conflict=True)
|
||||
host, drawer = _show_drawer(
|
||||
@@ -318,6 +320,10 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
assert unavailable.status_label.isVisible()
|
||||
assert unavailable.accessibleName() == "10:00-10:30 已约"
|
||||
available.click()
|
||||
if mode == "text":
|
||||
drawer.text_appointment_type_radio.click()
|
||||
assert drawer.text_appointment_type_radio.isChecked()
|
||||
assert not drawer.appointment_type_radio.isChecked()
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
@@ -328,7 +334,7 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"appointment_type": mode,
|
||||
"remark": "",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
|
||||
@@ -742,7 +742,7 @@ def test_im_entry_does_not_require_a_live_video_hint(
|
||||
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
|
||||
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
|
||||
assert waiting.isEnabled()
|
||||
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
|
||||
assert waiting.toolTip() == "打开患者 IM,可发送消息;通话能力由本次挂号决定"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -52,14 +52,14 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
|
||||
row = _row()
|
||||
before = copy.deepcopy(row)
|
||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30 · 视频问诊",
|
||||
)
|
||||
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||
"—", "2026-08-06 时间 —",
|
||||
"—", "2026-08-06 时间 — · 视频问诊",
|
||||
)
|
||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30 · 视频问诊")
|
||||
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||
for missing_id in (0, "", None):
|
||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||
@@ -70,13 +70,13 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
|
||||
host.set_rows([row])
|
||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||
assert blue.model.index(0, 4).data().splitlines() == [
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊", "— · 2026-08-06 时间 — · 视频问诊",
|
||||
]
|
||||
assert blue.model.index(0, 9).data() == "—"
|
||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||
blue.set_rows([{**row, "appointments": []}])
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊"
|
||||
legacy.close()
|
||||
blue.close()
|
||||
|
||||
|
||||
@@ -279,8 +279,10 @@ def test_workspace_workers_use_gui_thread_query_snapshots(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
application: QApplication,
|
||||
mode: str,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
@@ -317,6 +319,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
"doctor_id": 77,
|
||||
}
|
||||
dialog = _AppointmentDialog(row, repository=Repository())
|
||||
dialog.appointment_type.setCurrentIndex(dialog.appointment_type.findData(mode))
|
||||
application.processEvents()
|
||||
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
|
||||
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
|
||||
@@ -339,7 +342,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"appointment_type": mode,
|
||||
"remark": "复诊预约",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
|
||||
@@ -2565,11 +2565,14 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [None, "text"])
|
||||
def test_video_payload_keeps_three_identifiers_distinct(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
mode: str | None,
|
||||
) -> None:
|
||||
detail = _detail(41, name="视频患者")
|
||||
detail["appointment"]["appointment_type"] = mode
|
||||
|
||||
class Repository:
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
@@ -2586,6 +2589,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
{
|
||||
"source": "reception",
|
||||
"appointment_id": 41,
|
||||
"appointment_type": mode,
|
||||
"patient_id": 141,
|
||||
"diagnosis_id": 241,
|
||||
"patient_name": "视频患者",
|
||||
@@ -2593,6 +2597,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
"record": detail["appointment"],
|
||||
}
|
||||
]
|
||||
assert page.video_button.text() == ("图文沟通" if mode == "text" else "IM 问诊")
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCall",
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2, "appointment_id": 0},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Appointment policy must survive repository, launcher and lifecycle boundaries."""
|
||||
import logging
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.models import CallTicket
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.video.launcher import normalize_backend_ticket
|
||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
|
||||
|
||||
|
||||
def ticket(**policy):
|
||||
return CallTicket.from_dict({
|
||||
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||
"appointment_id": 456, "appointment_type": "text", **policy,
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, False, 0, 1, "true", "1", []])
|
||||
def test_only_actual_server_true_authorizes_calls(value):
|
||||
request = normalize_backend_ticket(ticket(can_video_call=value, can_audio_call=value))
|
||||
assert request.appointment_id == 456
|
||||
assert request.appointment_type == "text"
|
||||
assert request.patient_id == 8
|
||||
assert request.to_web_config()["can_video_call"] is False
|
||||
assert request.to_web_config()["can_audio_call"] is False
|
||||
|
||||
|
||||
def test_raw_policy_preserved_and_missing_policy_denies():
|
||||
request = normalize_backend_ticket(ticket())
|
||||
assert not request.can_video_call and not request.can_audio_call
|
||||
request = normalize_backend_ticket(ticket(appointment_type="phone", can_audio_call=True))
|
||||
assert request.can_audio_call and not request.can_video_call
|
||||
|
||||
|
||||
def test_repository_forwards_exact_appointment_and_media_type():
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def post(self, endpoint, payload):
|
||||
self.calls.append((endpoint, payload))
|
||||
return {"call_record_id": 99} if endpoint.endswith("startCall") else ticket().raw
|
||||
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
assert repository.get_call_ticket(8, 123, appointment_id=456).raw["appointment_type"] == "text"
|
||||
repository.start_call(123, 8, call_type=1, appointment_id=456)
|
||||
assert client.calls == [
|
||||
("tcm.diagnosis/getCallSignature", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}),
|
||||
("tcm.diagnosis/startCall", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456, "call_type": 1}),
|
||||
]
|
||||
|
||||
|
||||
def test_lifecycle_refreshes_exact_identity_and_uses_actual_media():
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.appointment_id = 456
|
||||
|
||||
def get_call_ticket(self, **payload):
|
||||
self.calls.append(payload)
|
||||
return ticket(appointment_id=self.appointment_id, can_audio_call=True)
|
||||
|
||||
def start_call(self, **payload):
|
||||
self.calls.append(payload)
|
||||
return {"call_record_id": 99}
|
||||
|
||||
def end_call(self, **payload):
|
||||
return {}
|
||||
|
||||
repository = Repository()
|
||||
lifecycle = OrderedCallLifecycle(normalize_backend_ticket(ticket()), repository, logging.getLogger(__name__))
|
||||
try:
|
||||
assert lifecycle.refresh_call_policy().result(timeout=2)["can_audio_call"] is True
|
||||
assert repository.calls[-1] == {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}
|
||||
assert lifecycle.start(call_type=1).result(timeout=2)
|
||||
assert repository.calls[-1]["call_type"] == 1
|
||||
assert repository.calls[-1]["appointment_id"] == 456
|
||||
repository.appointment_id = 457
|
||||
with pytest.raises(ValueError, match="当前问诊已变更"):
|
||||
lifecycle.refresh_call_policy().result(timeout=2)
|
||||
finally:
|
||||
lifecycle.end("test").result(timeout=2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_controller(monkeypatch):
|
||||
"""Exercise the real controller callbacks without launching Qt or Tencent."""
|
||||
from doctor_workstation import app as app_module
|
||||
|
||||
queued = []
|
||||
launched = []
|
||||
previews = []
|
||||
diagnoses = []
|
||||
dialog = object()
|
||||
|
||||
def get_call_ticket(patient_id, diagnosis_id, *, appointment_id):
|
||||
return ticket(patient_id=patient_id, diagnosis_id=diagnosis_id, appointment_id=appointment_id)
|
||||
|
||||
def launch_video_call(_ticket, **kwargs):
|
||||
window = SimpleNamespace(
|
||||
show=lambda: None,
|
||||
raise_=lambda: None,
|
||||
activateWindow=lambda: None,
|
||||
destroyed=SimpleNamespace(connect=lambda _callback: None),
|
||||
)
|
||||
call = SimpleNamespace(open_im=kwargs["open_im"], qt_window=window, close=lambda: None)
|
||||
launched.append((call, kwargs))
|
||||
return call
|
||||
|
||||
def open_diagnosis_by_id(diagnosis_id, *, modeless):
|
||||
diagnoses.append((diagnosis_id, modeless))
|
||||
return dialog
|
||||
|
||||
monkeypatch.setattr(app_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
monkeypatch.setattr(app_module, "launch_video_call", launch_video_call)
|
||||
monkeypatch.setattr(app_module, "show_toast", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(app_module, "_build_video_patient_case", lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(app_module, "WEBENGINE_AVAILABLE", True)
|
||||
monkeypatch.setattr(app_module, "QTimer", SimpleNamespace(singleShot=lambda _delay, callback: callback()))
|
||||
controller = SimpleNamespace(
|
||||
shell_window=SimpleNamespace(open_diagnosis_by_id=open_diagnosis_by_id),
|
||||
current_repository=SimpleNamespace(get_call_ticket=get_call_ticket),
|
||||
current_demo_mode=False,
|
||||
video_calls={}, video_pending={}, demo_video_dialogs={}, _pending_im_request=None,
|
||||
config=SimpleNamespace(video_mode="embedded", video_web_url=""),
|
||||
_show_video_preview=lambda window, diagnosis_dialog: previews.append((window, diagnosis_dialog)),
|
||||
)
|
||||
for method in ("_request_video", "_launch_video", "_video_ticket_error", "_open_video_diagnosis"):
|
||||
setattr(controller, method, MethodType(getattr(app_module.ApplicationController, method), controller))
|
||||
|
||||
def request(appointment_id, diagnosis_id=123):
|
||||
controller._request_video({
|
||||
"patient_id": 8, "diagnosis_id": diagnosis_id,
|
||||
"appointment_id": appointment_id, "mode": "im",
|
||||
})
|
||||
|
||||
def complete(index):
|
||||
function, callbacks = queued[index]
|
||||
callbacks["on_success"](function())
|
||||
|
||||
return SimpleNamespace(
|
||||
controller=controller, request=request, complete=complete, queued=queued,
|
||||
launched=launched, previews=previews, diagnoses=diagnoses, dialog=dialog,
|
||||
)
|
||||
|
||||
|
||||
def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
call, launch_args = case.launched[0]
|
||||
case.controller.video_calls["123:457"] = SimpleNamespace(qt_window=object())
|
||||
|
||||
launch_args["on_open_diagnosis"]()
|
||||
assert case.diagnoses == [(123, True)]
|
||||
assert case.previews == [(call.qt_window, case.dialog)]
|
||||
|
||||
case.controller.video_calls.pop("123:456")
|
||||
launch_args["on_open_diagnosis"]()
|
||||
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("second_diagnosis", [123, 124])
|
||||
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
|
||||
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.request(457, diagnosis_id=second_diagnosis)
|
||||
assert list(case.controller.video_pending) == [f"{second_diagnosis}:457"]
|
||||
|
||||
for index in completion_order:
|
||||
case.complete(index)
|
||||
|
||||
assert list(case.controller.video_calls) == [f"{second_diagnosis}:457"]
|
||||
assert len(case.launched) == 1
|
||||
assert case.controller.video_pending == {}
|
||||
assert case.controller._pending_im_request is None
|
||||
|
||||
|
||||
def test_reselecting_open_im_also_retires_other_pending_selection(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
original_call = case.launched[0][0]
|
||||
case.request(457)
|
||||
# The previous page may still be finishing its native shutdown. Treat a
|
||||
# reactivated current page like any other current selection.
|
||||
case.controller.video_calls["123:456"] = original_call
|
||||
case.request(456)
|
||||
case.complete(1)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
assert case.controller.video_pending == {}
|
||||
assert len(case.launched) == 1
|
||||
|
||||
|
||||
def test_repeated_pending_im_selection_does_not_duplicate_request(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.request(456)
|
||||
assert len(case.queued) == 1
|
||||
case.complete(0)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
|
||||
|
||||
def legacy_signature_repository(**changes):
|
||||
response = {
|
||||
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||
**changes,
|
||||
}
|
||||
repository = RemoteDoctorRepository(SimpleNamespace(post=lambda *_args: response))
|
||||
repository.patient_detail = lambda _diagnosis_id: {}
|
||||
return repository, response
|
||||
|
||||
|
||||
def test_legacy_signature_opens_im_with_requested_context_and_no_media(chat_controller):
|
||||
case = chat_controller
|
||||
repository, response = legacy_signature_repository()
|
||||
case.controller.current_repository = repository
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
assert case.launched[0][1]["open_im"] is True
|
||||
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||
assert request.appointment_id == 456
|
||||
assert request.target_user_id == "patient_8"
|
||||
assert request.can_video_call is False and request.can_audio_call is False
|
||||
assert request.call_disabled_reason
|
||||
assert "appointment_id" not in response # Never mutate the HTTP response/shared cache.
|
||||
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
|
||||
try:
|
||||
refreshed = lifecycle.refresh_call_policy().result(timeout=2)
|
||||
assert refreshed["appointmentId"] == 456
|
||||
assert refreshed["can_video_call"] is False and refreshed["can_audio_call"] is False
|
||||
finally:
|
||||
lifecycle.end("test").result(timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"diagnosis_id": 124}, {"patient_id": 9}, {"patientUserId": "patient_9"},
|
||||
{"diagnosis_id": None}, {"patient_id": None}, {"patientUserId": None},
|
||||
])
|
||||
def test_legacy_signature_requires_full_matching_identity(changes):
|
||||
repository, _ = legacy_signature_repository(**changes)
|
||||
with pytest.raises(ValueError, match="患者或诊单不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("returned_id", [457, 0, None, "", True, -1, 456.9, 456.0, "bad"])
|
||||
def test_explicit_wrong_or_invalid_appointment_is_never_replaced(returned_id):
|
||||
repository, _ = legacy_signature_repository(appointment_id=returned_id)
|
||||
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment", [
|
||||
{"can_video_call": True}, {"can_audio_call": True}, {"appointment_type": "text"},
|
||||
{"appointmentId": 457}, {"call_disabled_reason": ""},
|
||||
])
|
||||
def test_partial_policy_is_not_treated_as_legacy_signature(fragment):
|
||||
repository, _ = legacy_signature_repository(**fragment)
|
||||
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("returned_id", [456, "456"])
|
||||
def test_modern_exact_appointment_policy_is_preserved(returned_id):
|
||||
repository, _ = legacy_signature_repository(
|
||||
appointment_id=returned_id, appointment_type="video", can_video_call=True, can_audio_call=True,
|
||||
)
|
||||
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||
assert request.appointment_id == 456
|
||||
assert request.can_video_call is True and request.can_audio_call is True
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Appointment medium stays attached to its own row and opens text chat without RTC."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import _blue_appointment_text
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,label,video", [
|
||||
("text", "图文问诊", False), ("video", "视频问诊", True),
|
||||
(None, "视频问诊", True), (" ", "视频问诊", True),
|
||||
("phone", "电话问诊", False), ("unexpected", "未知", False),
|
||||
])
|
||||
def test_appointment_type_never_uses_diagnosis_visit_type(value, label, video):
|
||||
assert appointment_type_description(value) == label
|
||||
assert can_appointment_video(value) is video
|
||||
assert appointment_type_value({"appointment_type": value, "consultation_type": "复诊"}) == value or value == ""
|
||||
|
||||
|
||||
def test_nested_type_uses_current_appointment_id_not_another_latest():
|
||||
row = {"appointment_id": 12, "latest_appointment_type": "video", "appointments": [
|
||||
{"id": 11, "appointment_type": "video"}, {"id": 12, "appointment_type": "text"},
|
||||
]}
|
||||
assert appointment_type_value(row) == "text"
|
||||
assert "图文问诊" in _blue_appointment_text(row, row["appointments"][1])[1]
|
||||
assert "视频问诊" in _blue_appointment_text(row, row["appointments"][0])[1]
|
||||
assert appointment_type_value({"appointments": row["appointments"]}) == "unknown"
|
||||
|
||||
|
||||
def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application, monkeypatch):
|
||||
monkeypatch.setattr(appointments, "run_async", lambda *_a, **_kw: None)
|
||||
notices = []
|
||||
monkeypatch.setattr(appointments, "show_toast", lambda _parent, message, *_a: notices.append(message))
|
||||
page = appointments.AppointmentsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
rows = [{"id": 71, "patient_id": 501, "diagnosis_id": 501, "source_patient_id": 901,
|
||||
"patient_name": "图文患者", "doctor_id": 7, "status": 1, "appointment_type": "text"},
|
||||
{"id": 72, "patient_id": 502, "diagnosis_id": 502, "source_patient_id": 902,
|
||||
"patient_name": "视频患者", "doctor_id": 8, "status": 1, "appointment_type": "video"}]
|
||||
try:
|
||||
page._loaded({"lists": rows, "count": 2}, page._generation, False)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
text_row = next(i for i in range(2) if page.table.item(i, 0).data(Qt.ItemDataRole.UserRole)["id"] == 71)
|
||||
button = page.table.cellWidget(text_row, 10).findChild(QPushButton, "AppointmentImConsultButton")
|
||||
assert button.text() == "图文沟通" and button.isEnabled()
|
||||
assert page.table.cellWidget(text_row, 4).findChild(QLabel, "AppointmentModeLabel").text() == "图文问诊"
|
||||
button.click()
|
||||
assert emitted[-1]["appointment_id"] == 71
|
||||
assert emitted[-1]["diagnosis_id"] == 501
|
||||
assert emitted[-1]["patient_id"] == 901
|
||||
assert emitted[-1]["appointment_type"] == "text"
|
||||
assert emitted[-1]["mode"] == "im"
|
||||
assert not page.toolbar_qr_button.isEnabled()
|
||||
page._request_video_qr()
|
||||
assert notices[-1] == "本次挂号不支持视频问诊二维码。"
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
||||
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
||||
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
||||
"appointment_status": 1, "latest_appointment_id": 71,
|
||||
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
||||
"video_call_hint": {"state": "none"}}
|
||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
buttons = page.table_host.findChildren(QToolButton)
|
||||
text_button = next(button for button in buttons if button.text() == "图文沟通")
|
||||
assert text_button.isEnabled()
|
||||
assert page.video_button.isEnabled()
|
||||
assert not page.video_qr_toolbar_button.isEnabled()
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page._request_video()
|
||||
assert emitted[-1]["appointment_id"] == 71
|
||||
assert emitted[-1]["appointment_type"] == "text"
|
||||
assert emitted[-1]["mode"] == "im"
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
@@ -642,6 +642,13 @@ def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
||||
"userSig": "short-lived-ticket",
|
||||
"targetUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
"patientId": 8,
|
||||
"appointmentId": 0,
|
||||
"appointment_type": None,
|
||||
"appointment_type_desc": "",
|
||||
"can_video_call": False,
|
||||
"can_audio_call": False,
|
||||
"call_disabled_reason": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+91
-91
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CNqfE3p9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -60,6 +60,8 @@ const props = defineProps<{
|
||||
chatReady: Readonly<Ref<boolean>>
|
||||
chatBusy: Readonly<Ref<boolean>>
|
||||
notice: Readonly<Ref<string>>
|
||||
canVideoCall: Readonly<Ref<boolean>>
|
||||
callDisabledReason: Readonly<Ref<string>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
localRecordingState: Readonly<Ref<string>>
|
||||
@@ -308,6 +310,7 @@ watch(
|
||||
</button>
|
||||
<button
|
||||
class="primary-action"
|
||||
v-if="canVideoCall.value"
|
||||
type="button"
|
||||
:disabled="actionBusy || isCalling || !chatReady.value"
|
||||
@click="runAction(onStartVideo)"
|
||||
@@ -383,6 +386,9 @@ watch(
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<div v-if="callDisabledReason.value" class="inline-notice" role="status">
|
||||
{{ callDisabledReason.value }}
|
||||
</div>
|
||||
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
type Guard = (callType: number) => Promise<void>
|
||||
|
||||
/** Each SDK entrypoint uses the current conversation, including after async work. */
|
||||
export function installAppointmentCallGuard(api: Record<string, any>): (guard: Guard) => () => void {
|
||||
let activeGuard: Guard | undefined
|
||||
for (const method of ['call', 'calls', 'groupCall']) {
|
||||
const original = api[method]
|
||||
if (typeof original !== 'function') continue
|
||||
api[method] = async function (params: any, ...rest: any[]) {
|
||||
const guard = activeGuard
|
||||
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
|
||||
const type = params?.type === 1 ? 1 : 2
|
||||
const dispatched = { ...params, type }
|
||||
await guard(type)
|
||||
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
|
||||
return original.call(this, dispatched, ...rest)
|
||||
}
|
||||
}
|
||||
return (guard: Guard) => {
|
||||
activeGuard = guard
|
||||
return () => { if (activeGuard === guard) activeGuard = undefined }
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCallPolicy(policy: Record<string, unknown>, type: number): void {
|
||||
if ((type === 1 ? policy.can_audio_call : policy.can_video_call) !== true) {
|
||||
throw new Error(String(policy.call_disabled_reason || '本次挂号不支持该通话方式'))
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -6,6 +6,12 @@ declare module 'tim-upload-plugin' {
|
||||
}
|
||||
|
||||
interface DoctorCallConfig {
|
||||
patientId?: number | string | null
|
||||
appointmentId?: number
|
||||
appointment_type?: unknown
|
||||
can_video_call?: unknown
|
||||
can_audio_call?: unknown
|
||||
call_disabled_reason?: string
|
||||
SDKAppID?: number | string
|
||||
sdkAppId?: number | string
|
||||
userID?: string
|
||||
@@ -53,6 +59,7 @@ interface DoctorConsultationApi {
|
||||
startVideo(): Promise<void>
|
||||
hangup(): Promise<void>
|
||||
hostCallReady(ok: boolean, message?: string): void
|
||||
callPolicyResult(requestId: string, policy: Record<string, unknown>): void
|
||||
recordingResult(ok: boolean, message: string): void
|
||||
roomBindingResult(roomId: string, ok: boolean, message: string): void
|
||||
screenshotResult(ok: boolean, message: string): void
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@trtc/calls-uikit-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import { assertCallPolicy, installAppointmentCallGuard } from './appointment-call-guard'
|
||||
import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
@@ -20,6 +21,8 @@ type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'erro
|
||||
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
|
||||
|
||||
interface NormalizedCallConfig {
|
||||
appointmentId: number
|
||||
policy: Record<string, unknown>
|
||||
SDKAppID: number
|
||||
userID: string
|
||||
userSig: string
|
||||
@@ -77,6 +80,7 @@ interface BridgeMessage {
|
||||
event:
|
||||
| 'ready'
|
||||
| 'call-start-request'
|
||||
| 'call-policy-request'
|
||||
| 'status'
|
||||
| 'room'
|
||||
| 'hangup'
|
||||
@@ -86,6 +90,8 @@ interface BridgeMessage {
|
||||
| 'transcription-segment'
|
||||
| 'transcription-stop'
|
||||
diagnosisId?: number | string
|
||||
callType?: number
|
||||
requestId?: string
|
||||
status?: string
|
||||
roomId?: string
|
||||
message?: string
|
||||
@@ -179,6 +185,13 @@ const messages = ref<UiChatMessage[]>([])
|
||||
const chatReady = ref(false)
|
||||
const chatBusy = ref(false)
|
||||
const notice = ref('')
|
||||
const canVideoCall = ref(false)
|
||||
const callDisabledReason = ref('正在确认本次挂号通话权限')
|
||||
const registerCallGuard = installAppointmentCallGuard(TUICallKitAPI)
|
||||
let releaseCallGuard: (() => void) | undefined
|
||||
let contextGeneration = 0
|
||||
let policySequence = 0
|
||||
const pendingCallPolicies = new Map<string, (policy: Record<string, unknown>) => void>()
|
||||
const hasMoreMessages = ref(false)
|
||||
const transcriptionState = ref<TranscriptionState>('idle')
|
||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||
@@ -381,6 +394,13 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
: '患者'
|
||||
return {
|
||||
SDKAppID,
|
||||
appointmentId: Number(config.appointmentId || 0),
|
||||
policy: {
|
||||
can_video_call: config.can_video_call,
|
||||
can_audio_call: config.can_audio_call,
|
||||
appointment_type: config.appointment_type,
|
||||
call_disabled_reason: config.call_disabled_reason,
|
||||
},
|
||||
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||
userSig: cleanString(config.userSig, '用户签名'),
|
||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||
@@ -1740,13 +1760,42 @@ TUICallKitAPI.setCallback({
|
||||
TUICallKitAPI.setLanguage('zh-cn')
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
|
||||
function requestHostCallStart(): Promise<boolean> {
|
||||
function syncCallPolicy(policy: Record<string, unknown>): void {
|
||||
canVideoCall.value = policy.can_video_call === true
|
||||
callDisabledReason.value = policy.appointment_type === 'text'
|
||||
? '图文问诊,不支持音视频通话'
|
||||
: String(policy.call_disabled_reason || (canVideoCall.value ? '' : '本次挂号不支持视频通话'))
|
||||
}
|
||||
|
||||
function requestCallPolicy(): Promise<Record<string, unknown>> {
|
||||
if (!window.qtVideoBridge?.notify) return Promise.reject(new Error('无法确认本次挂号通话权限'))
|
||||
const requestId = `${contextGeneration}:${++policySequence}`
|
||||
return new Promise((resolve) => {
|
||||
pendingCallPolicies.set(requestId, resolve)
|
||||
emit({ source: 'doctor-call', event: 'call-policy-request', requestId, diagnosisId: activeConfig?.diagnosisId })
|
||||
window.setTimeout(() => {
|
||||
const pending = pendingCallPolicies.get(requestId)
|
||||
if (!pending) return
|
||||
pendingCallPolicies.delete(requestId)
|
||||
pending({ call_disabled_reason: '通话权限确认超时,请重试' })
|
||||
}, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
function callPolicyResult(requestId: string, policy: Record<string, unknown>): void {
|
||||
const resolve = pendingCallPolicies.get(requestId)
|
||||
if (!resolve) return
|
||||
pendingCallPolicies.delete(requestId)
|
||||
resolve(policy)
|
||||
}
|
||||
|
||||
function requestHostCallStart(callType = 2): Promise<boolean> {
|
||||
if (!activeConfig) return Promise.resolve(false)
|
||||
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
|
||||
if (!window.qtVideoBridge?.notify) return Promise.resolve(false)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const currentResolver = resolve
|
||||
resolveHostCallReady = currentResolver
|
||||
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
|
||||
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId, callType })
|
||||
window.setTimeout(() => {
|
||||
if (resolveHostCallReady !== currentResolver) return
|
||||
resolveHostCallReady = null
|
||||
@@ -1765,9 +1814,12 @@ function hostCallReady(ok: boolean, message = ''): void {
|
||||
async function startVideo(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
const config = activeConfig
|
||||
const generation = contextGeneration
|
||||
starting = true
|
||||
try {
|
||||
if (hangupNotification) await hangupNotification
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
hangupNotification = null
|
||||
callCycleGeneration += 1
|
||||
@@ -1782,8 +1834,6 @@ async function startVideo(): Promise<void> {
|
||||
localRecordingSessionId = ''
|
||||
localRecordingMimeType = ''
|
||||
notice.value = ''
|
||||
const allowed = await requestHostCallStart()
|
||||
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
||||
await TUICallKitAPI.init({
|
||||
SDKAppID: activeConfig.SDKAppID,
|
||||
userID: activeConfig.userID,
|
||||
@@ -1791,6 +1841,7 @@ async function startVideo(): Promise<void> {
|
||||
...(chat ? { tim: chat, isFromChat: true } : {}),
|
||||
})
|
||||
await nextTick()
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||
phase.value = 'dialing'
|
||||
statusText.value = '正在呼叫患者'
|
||||
appendVideoCallStatus('dialing', '正在呼叫患者')
|
||||
@@ -1807,11 +1858,12 @@ async function startVideo(): Promise<void> {
|
||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, '无法发起视频通话')
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error(message)
|
||||
phase.value = 'error'
|
||||
statusText.value = message
|
||||
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
|
||||
endNotified = true
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig?.diagnosisId, message })
|
||||
throw new Error(message)
|
||||
} finally {
|
||||
starting = false
|
||||
@@ -1867,6 +1919,31 @@ function roomBindingResult(roomId: string, ok: boolean, message: string): void {
|
||||
async function open(config: DoctorCallConfig): Promise<void> {
|
||||
if (activeConfig) await close()
|
||||
activeConfig = normalizeConfig(config)
|
||||
const current = activeConfig
|
||||
const generation = ++contextGeneration
|
||||
syncCallPolicy(current.policy)
|
||||
releaseCallGuard = registerCallGuard(async (type) => {
|
||||
const assertCurrent = () => {
|
||||
if (activeConfig !== current || generation !== contextGeneration) {
|
||||
throw new Error('当前问诊已切换,请重新发起通话')
|
||||
}
|
||||
if (document.visibilityState === 'hidden') throw new Error('请先返回本次挂号的聊天窗口')
|
||||
}
|
||||
const refresh = async () => {
|
||||
assertCurrent()
|
||||
const policy = await requestCallPolicy()
|
||||
assertCurrent()
|
||||
syncCallPolicy(policy)
|
||||
if (policy.appointmentId !== current.appointmentId) throw new Error('本次挂号已变更,请重新打开聊天窗口')
|
||||
assertCallPolicy(policy, type)
|
||||
}
|
||||
await refresh()
|
||||
if (!await requestHostCallStart(type)) throw new Error(notice.value || '服务器未能创建通话记录')
|
||||
assertCurrent()
|
||||
// Refresh again after asynchronous record creation, immediately before SDK dispatch.
|
||||
await refresh()
|
||||
assertCurrent()
|
||||
})
|
||||
mode.value = activeConfig.mode
|
||||
patientName.value = activeConfig.patientName
|
||||
patientCase.value = activeConfig.patientCase
|
||||
@@ -1908,6 +1985,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
releaseCallGuard?.()
|
||||
releaseCallGuard = undefined
|
||||
contextGeneration += 1
|
||||
for (const resolve of pendingCallPolicies.values()) resolve({})
|
||||
pendingCallPolicies.clear()
|
||||
resolveHostCallReady?.(false)
|
||||
resolveHostCallReady = null
|
||||
if (!endNotified) await hangup()
|
||||
if (hangupNotification) await hangupNotification
|
||||
unsubscribeTranscriber()
|
||||
@@ -1933,6 +2017,7 @@ window.doctorConsultation = {
|
||||
startVideo,
|
||||
hangup,
|
||||
hostCallReady,
|
||||
callPolicyResult,
|
||||
recordingResult,
|
||||
roomBindingResult,
|
||||
screenshotResult,
|
||||
@@ -1951,6 +2036,8 @@ createApp(App, {
|
||||
chatReady: readonly(chatReady),
|
||||
chatBusy: readonly(chatBusy),
|
||||
notice: readonly(notice),
|
||||
canVideoCall: readonly(canVideoCall),
|
||||
callDisabledReason: readonly(callDisabledReason),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
localRecordingState: readonly(localRecordingState),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ts = require('typescript')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/appointment-call-guard.ts'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const target = { exports: {} }
|
||||
new Function('module', 'exports', compiled)(target, target.exports)
|
||||
const { installAppointmentCallGuard, assertCallPolicy } = target.exports
|
||||
|
||||
for (const method of ['call', 'calls', 'groupCall']) {
|
||||
test(`${method}: current server policy controls every invocation`, async () => {
|
||||
const dispatched = []
|
||||
const api = { [method]: async (params) => dispatched.push(params.type) }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
await assert.rejects(api[method]({ type: 2 }), /请先打开/)
|
||||
let serverPolicy = { can_video_call: true, can_audio_call: true }
|
||||
let refreshes = 0
|
||||
register(async (type) => { refreshes++; assertCallPolicy(serverPolicy, type) })
|
||||
await api[method]({ type: 2 })
|
||||
serverPolicy = { appointment_type: 'text', can_audio_call: false, can_video_call: false }
|
||||
await assert.rejects(api[method]({ type: 2 }))
|
||||
await assert.rejects(api[method]({ type: 1 }))
|
||||
assert.deepEqual(dispatched, [2])
|
||||
assert.equal(refreshes, 3)
|
||||
})
|
||||
|
||||
test(`${method}: close or replacement rejects an in-flight authorization`, async () => {
|
||||
let resume
|
||||
let dispatched = 0
|
||||
const api = { [method]: async () => dispatched++ }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
const release = register(() => new Promise((resolve) => { resume = resolve }))
|
||||
const pending = api[method]({ type: 2 })
|
||||
release()
|
||||
register(async () => {})
|
||||
resume()
|
||||
await assert.rejects(pending, /已切换/)
|
||||
assert.equal(dispatched, 0)
|
||||
release() // Releasing the old window cannot remove the replacement guard.
|
||||
await api[method]({ type: 1 })
|
||||
assert.equal(dispatched, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('strict booleans fail closed, audio and video permissions remain separate', () => {
|
||||
for (const value of [undefined, null, false, 0, 1, 'true', '1']) {
|
||||
assert.throws(() => assertCallPolicy({ can_video_call: value }, 2))
|
||||
assert.throws(() => assertCallPolicy({ can_audio_call: value }, 1))
|
||||
}
|
||||
assert.doesNotThrow(() => assertCallPolicy({ can_audio_call: true }, 1))
|
||||
assert.throws(() => assertCallPolicy({ can_audio_call: true }, 2))
|
||||
})
|
||||
|
||||
test('an awaiting caller cannot change the authorized media type', async () => {
|
||||
let resume
|
||||
let actual
|
||||
const api = { calls: async (params) => { actual = params.type } }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
register(() => new Promise((resolve) => { resume = resolve }))
|
||||
const params = { type: 1 }
|
||||
const pending = api.calls(params)
|
||||
params.type = 2
|
||||
resume()
|
||||
await pending
|
||||
assert.equal(actual, 1)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
# 挂号类型展示与图文问诊通话限制
|
||||
|
||||
按用户确认,仅使用现有视频问诊、图文问诊,不新增线下面诊选项。新预约默认视频,历史电话记录继续准确展示。
|
||||
|
||||
## 交付
|
||||
|
||||
- 挂号编辑和预约抽屉明确标注“挂号类型”。我的患者、诊单 PC/H5 显示当前挂号类型,多次挂号分别展示;未挂号显示 `—`。
|
||||
- 医生面诊 PC/H5 的列表及详情显示“面诊类型”;聊天窗口传递并核对本次挂号 ID,避免同一诊单多条挂号类型串用。
|
||||
- 图文问诊保留文本、图片、文件沟通,隐藏语音、视频及群视频入口。直接调用群视频方法或 SDK `call/calls/groupCall` 也会校验;发起前重新读取服务端类型,拒绝已改为图文的挂号。
|
||||
- 服务端签名响应携带本次挂号类型及通话权限;发起通话接口在创建通话记录前再次校验。签名仍供图文聊天使用,未禁用聊天登录。
|
||||
- 视频二维码及旁观入口仅供视频问诊使用,普通诊单二维码仍支持图文问诊。
|
||||
- 多条有效挂号未指定 ID 时允许获取聊天签名,但不猜测可视频的挂号;明确挂号 ID 必须属于当前诊单。历史空类型仅在挂号确实存在时沿用原视频默认值。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node --test admin/tests/appointment-call-mode.test.cjs`:7 项行为回归通过,包含实际 SFC 脚本执行及真实聊天模板渲染,校验图文无通话按钮、视频仍有按钮、直调拦截、服务端类型变化及跨窗口状态切换。
|
||||
- `node admin/scripts/verify-appointment-type.cjs`:9 个 Vue 组件脚本、模板、样式编译通过,3 个聊天入口传递挂号 ID 的约束通过。
|
||||
- `php server/tests/AppointmentCallPolicyTest.php`:34 个断言通过,内存模型替身,验证挂号选择、图文限制、跨诊单 ID 拒绝及旧数据兼容。
|
||||
- `php server/tests/AppointmentTypeTest.php`、`php server/tests/CallSignatureIdentityContractTest.php` 通过;修改 PHP 语法检查通过。
|
||||
- 全量 `vue-tsc` 已执行,仍有原有模块报错;本次新增工具和聊天组件无报错。编辑过的两个列表仅在原有 `@command` 回调出现已有 `cmd` 隐式 any,未在本次扩大修复范围。输出见 `typecheck.log`。
|
||||
- `git diff --check` 通过。
|
||||
- 全量生产构建通过(1 分 53 秒),输出到任务临时目录,未覆盖站点发布目录;完整输出见 `build.log`,仍有既有的大分包警告。
|
||||
|
||||
分项记录:[页面](ui.md)、[患者列表后端](backend.md)、[服务端通话策略](call-policy.md)。测试使用虚构数据,不连接业务数据库,不生成真实二维码,不登录腾讯云或拨打实际通话。未进行完整浏览器/真实租户联调。
|
||||
|
||||
## 上线
|
||||
|
||||
本次未部署,无新增表字段或 SQL 迁移。需将前后端一起上线:新页面以签名接口返回的通话权限为准,旧后端不返回确认字段时不会开放通话按钮。
|
||||
@@ -0,0 +1,19 @@
|
||||
# 挂号类型展示:后端交付
|
||||
|
||||
按用户澄清,仅补已有问诊类型的展示;未新增线下面诊选项。
|
||||
|
||||
## 变更
|
||||
|
||||
- `server/app/adminapi/lists/firstvisit/MyPatientLists.php`:查询主挂号的 `appointment_type`,并从同一条主挂号返回 `appointment_id`、医生、时间、`appointment_type` 与 `appointment_type_desc`。沿用当前日期和状态筛选的主挂号选择逻辑,避免把最新一条挂号类型拼到另一条挂号上。
|
||||
- 无挂号时返回 `appointment_type = null`、`appointment_type_desc = ''`,避免误示为视频挂号。已有挂号的历史空值仍按原枚举规则显示视频问诊。
|
||||
- `server/app/adminapi/lists/firstvisit/MyPatientProgressLists.php`:类型和标签统一使用 `AppointmentTypeEnum`。历史 `phone` 显示电话问诊,未知类型显示未知,不再错误回退为面诊。
|
||||
- `server/tests/AppointmentTypeTest.php`:补主挂号选择与类型一致性、日期/状态切换、无挂号、历史空值/电话/未知标签的回归覆盖;确认 `offline` 仍不允许写入。
|
||||
|
||||
## 验证
|
||||
|
||||
- `php server/tests/AppointmentTypeTest.php`:通过。
|
||||
- 上述两个列表文件及测试文件的 `php -l`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- 已确认枚举、创建/编辑验证逻辑及 SQL 文件均无本次变更。
|
||||
|
||||
测试未连接业务数据库、未执行 SQL 或迁移、未发出外部请求。视频通话限制由主代理另行实现和验证。
|
||||
@@ -0,0 +1,495 @@
|
||||
vite v6.4.2 building for production...
|
||||
|
||||
WARN
|
||||
(!) outDir D:\web\zyt\artifacts\appointment-modes\admin-build is not inside project root and will not be emptied.
|
||||
Use --emptyOutDir to override.
|
||||
|
||||
|
||||
transforming...
|
||||
✓ 4061 modules transformed.
|
||||
|
||||
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
||||
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
../artifacts/appointment-modes/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
||||
../artifacts/appointment-modes/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DONCMk_U.css 8.04 kB │ gzip: 2.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-preview-DebWNacj.js 0.16 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-B5lAmlRD.js 0.18 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund-log-Dte4TYSk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account-adjust-B00EjgEk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-o13C0dUX.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Bw34X5yl.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-fJdDXmQk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-ZSwm2OnL.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/perm-CxGp7d9E.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog-DhGQ2qc5.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/weapp-AW0lTawR.js 0.20 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton-BUP3IK_K.js 0.21 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-n8Jap8KU.js 0.21 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DobNH3vu.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-B6XQI8aF.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DyTWIq8-.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DcP285OM.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CpN5NTKb.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CscKSbQv.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-DpRW2C_C.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D9QQ1l2O.js 0.23 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/auth-C5D7rPvf.js 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CBO2zICf.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/relations-add-D1VlWUUa.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mobile-style-DwsiSMiK.js 0.26 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CHvGoI1A.js 0.27 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cost-edit-Bg-Uyd2c.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit-C9uvAsWe.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/404-3rkYd7tU.js 0.31 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/data-table-SmfBi9Sg.js 0.31 kB │ gzip: 0.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit-C7hYj3ER.js 0.33 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-Dd2rx3Ot.js 0.35 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/consumer-B3GKjsdE.js 0.35 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/user-Ca4bJD6d.js 0.37 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/role-DTJVNPTP.js 0.39 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useDictOptions-Dn9M7zwp.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzeCW0Uz.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/admin-BOSvnXa7.js 0.42 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-s8eO9hBN.js 0.44 kB │ gzip: 0.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pay-CYh6c1Jc.js 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/department-jIolW8HD.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-DxVtimm9.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-COxSBtU5.js 0.47 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-C-UmNM7-.js 0.48 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/post-BM66vrTr.js 0.49 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/overflow-CvDxI8pV.js 0.49 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-DcKIqlQW.js 0.50 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/message-NjmKbrE0.js 0.50 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/403-Bd7P9v6E.js 0.52 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CwusgJ4Z.js 0.53 kB │ gzip: 0.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-C-oRPuSk.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-CY72vprk.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-nav-BdWkHCFM.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DnglhtO0.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-set-O2cSQVkP.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-D3WqzAia.js 0.55 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-vjBAK2HA.js 0.56 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/fans-eQIJEPPO.js 0.60 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-DZ31aYPg.js 0.60 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-lqO8LPXd.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-LZa4samX.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-0Lb4ZZ78.js 0.61 kB │ gzip: 0.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C9CTu6xY.js 0.63 kB │ gzip: 0.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CFowjcfc.js 0.63 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/link-DGRRT5eg.js 0.64 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/asset-D92fftiF.js 0.67 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Bt_MGGD6.js 0.69 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useMediaSourceOptions-B0L3IlgH.js 0.70 kB │ gzip: 0.42 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-4NVOwZND.js 0.70 kB │ gzip: 0.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/website-CBS8EOwn.js 0.74 kB │ gzip: 0.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-C47G34h8.js 0.76 kB │ gzip: 0.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/self_input_stats-BMoDYPed.js 0.79 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-ChFP1uE8.js 0.81 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dict-DqKNj29A.js 0.81 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-D879PDoD.js 0.82 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-NopGY6qR.js 0.84 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-1ur9wwmZ.js 0.86 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/error-Bn-Kyayl.js 0.89 kB │ gzip: 0.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BiL-qD2M.js 0.92 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/wx_oa-B5L52qTa.js 1.04 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/article-hs8A9Jdu.js 1.07 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-ClYX-hV1.js 1.08 kB │ gzip: 0.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/rich_text-Dk0jS3Pc.js 1.10 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cache-CWFtbfTm.js 1.13 kB │ gzip: 0.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DCmzItce.js 1.21 kB │ gzip: 0.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/finance-DA6_SyDw.js 1.30 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc-DQbWYqv7.js 1.31 kB │ gzip: 0.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/doctor-DyZGuUZC.js 1.32 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C21u_UoH.js 1.33 kB │ gzip: 0.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-BAht1aZ2.js 1.33 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-CeY2TUv-.js 1.37 kB │ gzip: 0.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-BJGtt-xN.js 1.38 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/file-BTnyaqZX.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/upload-DHrBQCOO.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-NSr7rO7a.js 1.42 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-C4bf87lU.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DLNXfvlb.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_style_index_0_lang-mxcCSl8u.js 1.46 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-DVZEALSU.js 1.47 kB │ gzip: 0.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-BePIgx8X.js 1.50 kB │ gzip: 0.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-BxpNhh7J.js 1.55 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-oWJA6Uib.js 1.60 kB │ gzip: 0.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BACvjW0Z.js 1.62 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/style-DHri-b9V.js 1.62 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-C9XoZuna.js 1.66 kB │ gzip: 0.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/statistics-B3M41WAU.js 1.69 kB │ gzip: 1.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DNKk0KAC.js 1.71 kB │ gzip: 0.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order-D2TrFdV8.js 1.74 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/environment-C74pQttO.js 1.74 kB │ gzip: 0.74 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setup-Dz_APrpo.js 1.77 kB │ gzip: 1.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useMenuOa-DFdWMJUR.js 1.82 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-setting-D_7bJ_WP.js 1.88 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzDASedl.js 1.90 kB │ gzip: 0.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-E0vpVDJX.js 1.94 kB │ gzip: 1.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B9qvdXe9.js 2.03 kB │ gzip: 0.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B3x_V6St.js 2.04 kB │ gzip: 1.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B7ZehsXP.js 2.08 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DMVXI7dX.js 2.09 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CFyAuY4i.js 2.09 kB │ gzip: 0.95 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/open_setting-521tiVc_.js 2.10 kB │ gzip: 1.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-0BnrgRc3.js 2.12 kB │ gzip: 1.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CpPfqdLb.js 2.16 kB │ gzip: 1.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/filing-Cqmunz7R.js 2.17 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-ClW9LZFF.js 2.18 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/icon-qQvuPtJo.js 2.23 kB │ gzip: 0.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Dur4SMQq.js 2.24 kB │ gzip: 1.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-vpDrHDku.js 2.28 kB │ gzip: 1.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C366kQIF.js 2.29 kB │ gzip: 1.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-BOTDmjKh.js 2.40 kB │ gzip: 1.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CWND1gXs.js 2.40 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/stats-BZP8tLtW.js 2.45 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-DS49GzuV.js 2.51 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-attr-BzWXYYXP.js 2.51 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dhx2yo7b.js 2.52 kB │ gzip: 1.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-p3Zf_YQP.js 2.56 kB │ gzip: 1.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/change-password-YIODZgIx.js 2.57 kB │ gzip: 1.35 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-DMG92gvp.js 2.61 kB │ gzip: 1.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tabbar-C4RTjgc5.js 2.66 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/file-CT3DBwWs.js 2.73 kB │ gzip: 1.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-uPRl_lL4.js 2.74 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-VQL80FaN.js 2.77 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-BeAQ08_1.js 2.77 kB │ gzip: 1.08 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-LSZveKRb.js 2.80 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CkZpjrdt.js 2.81 kB │ gzip: 1.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C659Qo0h.js 2.85 kB │ gzip: 1.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/follow_reply-DVMErqzs.js 2.85 kB │ gzip: 1.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D1k3lHzy.js 2.86 kB │ gzip: 1.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-CVc0n4pV.js 2.86 kB │ gzip: 1.35 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-lmr2i5Bj.js 2.89 kB │ gzip: 1.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/default_reply-CpyjKv3V.js 2.91 kB │ gzip: 1.54 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-gdH1LeQj.js 2.96 kB │ gzip: 1.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/protocol-D4wxifpO.js 2.98 kB │ gzip: 1.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-PJQQwYnV.js 2.99 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-Drt73fzr.js 3.03 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-C8d76FGV.js 3.04 kB │ gzip: 1.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BIYyttaB.js 3.05 kB │ gzip: 1.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-D2r9RPoC.js 3.06 kB │ gzip: 1.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/keyword_reply-DBgqHDjZ.js 3.11 kB │ gzip: 1.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DweAr7Ym.js 3.12 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BUsM_LI5.js 3.21 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CnIVKgZ-.js 3.25 kB │ gzip: 1.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-BRrEuHV-.js 3.26 kB │ gzip: 1.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc_details-B_6wZRHo.js 3.39 kB │ gzip: 1.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DqGrHNcO.js 3.39 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-phcPclpA.js 3.42 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CUge-qSO.js 3.43 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BkwnyzI2.js 3.44 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/balance_details-Ds1LzmaH.js 3.48 kB │ gzip: 1.63 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DtdgFl7O.js 3.48 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-cQgN4IT9.js 3.52 kB │ gzip: 1.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/journal-CY6ZpS6p.js 3.73 kB │ gzip: 1.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DKvnMaof.js 3.73 kB │ gzip: 1.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/detail-BV7O2HIL.js 3.76 kB │ gzip: 1.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BuTdyKef.js 3.78 kB │ gzip: 1.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-BSTysfqz.js 3.78 kB │ gzip: 1.77 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-bI1lUcbW.js 3.91 kB │ gzip: 1.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BY7HFTA8.js 3.98 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-BbOdj1-7.js 4.02 kB │ gzip: 1.73 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C8TwWqpc.js 4.11 kB │ gzip: 1.65 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CxWRNLqZ.js 4.11 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/first_visit-CvA69-XM.js 4.13 kB │ gzip: 0.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/detail-BRablUBf.js 4.15 kB │ gzip: 1.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-DgY38P4R.js 4.17 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bh5w94E3.js 4.22 kB │ gzip: 1.92 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-COfv9A2s.js 4.26 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B2NSVhqr.js 4.37 kB │ gzip: 2.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-DP4Gin1a.js 4.40 kB │ gzip: 2.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/recharge_record-BzIkkNm9.js 4.43 kB │ gzip: 1.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B4J-ZHfW.js 4.45 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BwbCBYM9.js 4.47 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-exKfRfkT.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bs0vH486.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-Dh1zljh0.js 4.49 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CvCnQtvN.js 4.51 kB │ gzip: 1.75 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-JNdaKBs5.js 4.53 kB │ gzip: 2.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-6cheoyXp.js 4.54 kB │ gzip: 2.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BLgjrOkJ.js 4.55 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/paiban-idrEqd0C.js 4.56 kB │ gzip: 2.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DAvdgv_l.js 4.57 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login_register-BcBecvF5.js 4.59 kB │ gzip: 2.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-CwUw39uH.js 4.66 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C0B1ZpBc.js 4.76 kB │ gzip: 2.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dozjlzsq.js 4.92 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-BBC7JIUD.js 5.04 kB │ gzip: 1.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CeStvPlb.js 5.18 kB │ gzip: 2.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-CwUhJWnn.js 5.28 kB │ gzip: 2.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-gh639-1m.js 5.32 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-rDSQ6cWW.js 5.53 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-CfJ7Ipck.js 5.55 kB │ gzip: 2.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Dw9aZRHs.js 5.56 kB │ gzip: 1.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BGsSkFG1.js 5.59 kB │ gzip: 1.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-oXdkNbAH.js 5.62 kB │ gzip: 2.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-853P--U5.js 5.62 kB │ gzip: 2.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tongji-BTDH_24t.js 5.66 kB │ gzip: 2.31 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-Cy0KYlPt.js 5.82 kB │ gzip: 2.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-BeSIO_4H.js 5.84 kB │ gzip: 2.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-C1gPiZEq.js 5.91 kB │ gzip: 2.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login-DJgbVJrh.js 6.03 kB │ gzip: 2.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund_record-MA_WT5dD.js 6.27 kB │ gzip: 2.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-CIc3FdV9.js 6.34 kB │ gzip: 2.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D4UoI3lC.js 6.35 kB │ gzip: 3.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/information-BZXrACCh.js 6.36 kB │ gzip: 1.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-B64KEfoD.js 6.48 kB │ gzip: 2.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account_cost-DdPYY7oD.js 6.56 kB │ gzip: 2.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-QLnHkNyv.js 6.61 kB │ gzip: 2.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-hHC-HY2w.js 6.61 kB │ gzip: 2.62 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-6aflqrhb.js 6.77 kB │ gzip: 2.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/readonly-Bc7S11g_.js 6.98 kB │ gzip: 2.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-BA72xnJ0.js 7.32 kB │ gzip: 3.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKaLV_7V.js 7.35 kB │ gzip: 2.66 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setting-DK-S6SHN.js 7.35 kB │ gzip: 2.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/weapp-BaIevkiG.js 7.39 kB │ gzip: 2.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-RSmge3tZ.js 7.65 kB │ gzip: 3.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/config-h4uBlWvN.js 7.77 kB │ gzip: 2.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-Bq97RGBJ.js 7.84 kB │ gzip: 3.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-call-CzyPR-gp.js 7.85 kB │ gzip: 3.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tcm-DVMUVofm.js 8.99 kB │ gzip: 1.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-vUD1ND3r.js 9.00 kB │ gzip: 2.83 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BsQ-IFkz.js 9.49 kB │ gzip: 3.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-lzEwKV5j.js 9.58 kB │ gzip: 3.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-D_iyqZcd.js 9.74 kB │ gzip: 3.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-Chzff2sc.js 9.99 kB │ gzip: 3.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CUd56fl0.js 10.06 kB │ gzip: 3.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DfXvRPp3.js 10.33 kB │ gzip: 3.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-DD3meqcJ.js 10.36 kB │ gzip: 4.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DDd8ec_S.js 10.37 kB │ gzip: 3.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/progress-DuXbNWsm.js 11.18 kB │ gzip: 4.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-Cw7NQEcD.js 11.18 kB │ gzip: 4.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DGvtXd9k.js 11.30 kB │ gzip: 4.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-Cm3Hfa5N.js 11.34 kB │ gzip: 4.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-BDOteF3G.js 11.44 kB │ gzip: 3.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-Blnuj7Qu.js 11.74 kB │ gzip: 4.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-I1e_QZKi.js 12.15 kB │ gzip: 4.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-2qbsbc6G.js 12.58 kB │ gzip: 4.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-Dql8RbMo.js 12.76 kB │ gzip: 3.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CcMovsjP.js 13.00 kB │ gzip: 4.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B_Kh6a1u.js 13.11 kB │ gzip: 3.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-BpUP9yLV.js 13.83 kB │ gzip: 4.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CWnyJh4D.js 14.26 kB │ gzip: 5.90 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/guahao-DEn7doZO.js 15.06 kB │ gzip: 4.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-CUAXb84X.js 15.22 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BrOS-AAf.js 15.62 kB │ gzip: 6.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BRhh-vvk.js 15.90 kB │ gzip: 5.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-b7xryZ1R.js 15.90 kB │ gzip: 4.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CMnX8dfi.js 16.34 kB │ gzip: 6.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-NRre3yrk.js 16.35 kB │ gzip: 5.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CRhDpVNm.js 16.60 kB │ gzip: 6.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-DAmQQSVZ.js 17.15 kB │ gzip: 6.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DQb8OBRa.js 17.84 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/roster-BVq4roST.js 18.09 kB │ gzip: 5.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DXdrxgPI.js 18.19 kB │ gzip: 6.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-kf-hJGbx.js 18.23 kB │ gzip: 6.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CVeeMncf.js 18.60 kB │ gzip: 5.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-D6ufstYr.js 19.11 kB │ gzip: 6.95 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-CdrbUm1n.js 21.16 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CS6MIRqR.js 21.91 kB │ gzip: 7.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-C1r7FTAR.js 22.29 kB │ gzip: 5.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BpYt_Hee.js 22.96 kB │ gzip: 8.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-BPhRn5lY.js 24.97 kB │ gzip: 7.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list_h5-cNTYH_iO.js 27.86 kB │ gzip: 9.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/qywx-CJq7NVl4.js 30.35 kB │ gzip: 10.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-DSAjNXOT.js 30.35 kB │ gzip: 9.74 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-B1m1GnhE.js 35.83 kB │ gzip: 9.75 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-C-_be8Ci.js 35.86 kB │ gzip: 9.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CmQWbBsG.js 36.70 kB │ gzip: 11.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index_h5-BSNbLohA.js 37.92 kB │ gzip: 11.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CnqPWEF7.js 37.98 kB │ gzip: 13.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CAiJ9mjS.js 40.10 kB │ gzip: 11.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-BwYuljL9.js 48.77 kB │ gzip: 13.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Am7Jc2zt.js 51.46 kB │ gzip: 15.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-QkQBeLjo.js 55.03 kB │ gzip: 15.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DpMMQbZW.js 69.54 kB │ gzip: 21.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-D8HJelzr.js 87.40 kB │ gzip: 23.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C4cvelAi.js 88.42 kB │ gzip: 25.65 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list-D_i5USUp.js 114.89 kB │ gzip: 31.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-B8Us1G-6.js 139.58 kB │ gzip: 36.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C1oKTAGC.js 290.22 kB │ gzip: 92.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
||||
|
||||
WARN
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
|
||||
✓ built in 1m 53s
|
||||
@@ -0,0 +1,39 @@
|
||||
# 挂号通话策略
|
||||
|
||||
已新增 `server/app/common/service/AppointmentCallPolicy.php`,未修改问诊方式枚举、DiagnosisLogic、校验器或聊天页面。
|
||||
|
||||
## 集成 API
|
||||
|
||||
```php
|
||||
AppointmentCallPolicy::resolve(int $diagnosisId, int $appointmentId = 0): array
|
||||
```
|
||||
|
||||
返回固定字段:
|
||||
|
||||
- `appointment_id`: int
|
||||
- `appointment_type`: string|null
|
||||
- `appointment_type_desc`: string
|
||||
- `can_video_call`: bool
|
||||
- `can_audio_call`: bool
|
||||
- `call_disabled_reason`: string
|
||||
|
||||
数据库查询固定用 `Appointment::where('patient_id', $diagnosisId)`;挂号表的 patient_id 是诊单 ID,不是患者来源表 ID。显式挂号不在当前诊单的查询结果内时抛出 `RuntimeException('指定挂号不存在或不属于当前诊单')`,不会回退猜测另一条挂号。
|
||||
|
||||
未指定挂号时:一条 status=1 自动采用;多条 status=1 返回 id=0、type=null、禁止音视频、原因 `请指定本次挂号`。没有有效预约时,在 status=3/4 历史记录中按预约日期、时间、ID 降序选择一条;不拿取消记录作为历史回退。没有可选记录返回 id=0、type=null、描述 `未挂号`、禁止音视频。
|
||||
|
||||
沿用已有 `AppointmentTypeEnum::normalizeStored`:确实存在但类型为空的历史挂号兼容 video;缺少挂号不会凭空默认 video。video 可以音视频,历史 phone 只可以音频,text 及其他类型均禁止音视频。status=2 保留准确类型但禁止通话;未知状态同样禁止。没有新增 offline 或其他枚举。
|
||||
|
||||
## 无数据库验证
|
||||
|
||||
另提供 `resolveFromAppointments(array $appointments, int $appointmentId = 0)` 与 `policyForAppointment(?array $appointment)` 纯函数入口。前者的数组必须已限制为当前诊单的挂号。
|
||||
|
||||
执行:
|
||||
|
||||
```text
|
||||
php -l server/app/common/service/AppointmentCallPolicy.php
|
||||
php server/tests/AppointmentCallPolicyTest.php
|
||||
```
|
||||
|
||||
语法检查通过;34 项断言通过。测试仅加载策略和枚举,使用内存模型替身验证实际 resolve 的诊单过滤与伪造挂号 ID,完全不启动数据库或网络连接。覆盖图文、视频、历史电话、空类型、未知类型、取消状态、多条有效挂号、明确选择、按日期/时间/ID 选择历史记录和无挂号。
|
||||
|
||||
`git diff --check` 通过。
|
||||
@@ -0,0 +1,91 @@
|
||||
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
|
||||
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
|
||||
src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
|
||||
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
|
||||
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
|
||||
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
|
||||
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||
src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||
src/views/first_visit/wecom_promotion/index.vue(121,43): error TS7006: Parameter 'checked' implicitly has an 'any' type.
|
||||
src/views/first_visit/wecom_promotion/index.vue(217,226): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/tcm/appointment/list.vue(266,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/index.vue(392,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Appointment type UI changes
|
||||
|
||||
User clarification applied: this change displays the existing appointment types. It does not add an offline / 线下面诊 option or alter the default video selection.
|
||||
|
||||
## Delivered
|
||||
|
||||
- `consumer/prescription/guahao.vue`: explicit 挂号类型 column and edit-field label, using the shared description helper.
|
||||
- `first_visit/my_patients/index.vue`: 挂号类型 column; patients without an appointment show `—`.
|
||||
- Diagnosis desktop and H5: each booking displays its own type. Desktop multi-booking type entries include doctor and time so types remain attributable. The fallback row preserves appointment type.
|
||||
- Appointment desktop and H5: 面诊类型 in the list/card and detail view. Non-video bookings open 图文沟通. Chat signature requests and the chat dialog receive the selected appointment ID.
|
||||
- Shared booking drawer: 挂号类型 label, with the existing video and text options retained.
|
||||
- Video QR and video observation entries reject non-video bookings. Diagnosis selection finds an active video booking for video-only actions, retaining its actual appointment ID, type, doctor, and time. Normal diagnosis QR continues to support any active booking, including text.
|
||||
- H5 type labels have enough width; multi-booking cards wrap type onto its own line.
|
||||
|
||||
## Verification
|
||||
|
||||
- Vue compiler SFC parse, script compilation, and template compilation passed for all seven edited Vue files.
|
||||
- Executed the actual diagnosis helper declarations extracted via the TypeScript AST for desktop and H5. Both passed: unbooked patient has no video; active text keeps normal diagnosis QR but has no video; legacy null type preserves video compatibility; mixed text / completed video / active video selects the active video appointment and its doctor, ID, and time; phone is not eligible for video.
|
||||
- `git diff --check` passed for the edited UI files.
|
||||
- No live API calls, QR generation requests, or video calls were made. Browser visual verification and integrated checks remain with the parent task.
|
||||
|
||||
## Coordination notes
|
||||
|
||||
- Depends on the parent task's `canAppointmentVideoCall` utility and backend appointment type fields / appointment ID enforcement.
|
||||
- Inspected `DiagnosisLogic::generateMiniProgramQrcode`: login QR explicitly uses `doctor_id` as the scene ID; `appointment_id` is not consumed. Existing QR payload semantics are preserved. The selected video booking supplies the correct doctor.
|
||||
- `.trellis/` is absent in this checkout, as noted by the parent task. No subagents were spawned.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 挂号类型检索与 IM 无效账号修复
|
||||
|
||||
## 完成内容
|
||||
|
||||
1. 截图对应的挂号列表增加“挂号类型”:全部、视频问诊、图文问诊。点击查询、切换分页及重置均携带/清除该条件,列表总数和状态角标使用同一条件。旧的空类型按既有规则归入视频,详情见 [appointment-filter.md](./appointment-filter.md)。
|
||||
2. 修复 `invalid Operator_Account or Peer_Account` 大量重复出现的问题:后台成员并不一定注册过腾讯 IM,读取前先用只读 `account_check` 批量核验,每请求最多 100 个账号,再同步已注册医生与患者的会话。不会为查询历史批量创建空账号。接口依据见 [account-check.md](./account-check.md)。
|
||||
3. 未注册或已失效的医生账号跳过,并在页面显示跳过数量;不作为同步失败逐个刷屏,已有本地归档照常显示。患者账号缺失、权限错误、网络错误和不完整的校验结果仍明确报错,不会误判为“没有消息”。其他真实会话错误超过 3 项时可展开查看。
|
||||
4. 核验与历史读取分开执行,避免多次云端调用叠加造成一次 HTTP 请求超时。旧版本尚未完成的同步 token 会重新核验,已归档数量保留,已写入消息继续幂等去重。每个新同步周期重新核验,新开通账号可自动参与后续同步。
|
||||
|
||||
## 验证
|
||||
|
||||
- 前端及筛选回归共 36 项通过:挂号类型筛选 8 项、聊天历史 16 项、聊天归档触发 5 项、既有图文通话限制 7 项。
|
||||
- `php server/tests/TencentImAccountCheckTest.php`:真实账号检查服务,HTTP 替身,覆盖 100/101 边界、请求响应契约、缺失账号和真实错误的区别。
|
||||
- `php server/tests/ImChatArchiveTest.php`:实际归档流程,模拟 184 个医生中 183 个未注册;只向有效医生发送漫游请求,同时验证第二批失败不推进、患者账号缺失保留归档、所有医生缺失、旧 token 迁移及原有历史完整性用例。
|
||||
- 分页器、回调(81 项)、定时补偿回归通过。修改的 PHP 语法及 `git diff --check -- admin server` 通过。
|
||||
- 管理端构建日志见 [build.log](./build.log)。
|
||||
|
||||
测试使用虚构数据、严格内存替身和内存 SQLite,不访问业务数据库,也没有调用真实腾讯接口。
|
||||
|
||||
## 发布
|
||||
|
||||
发布本次 `admin` 和 `server` 修改后刷新页面即可;本轮不需要新增数据库字段或执行 SQL。当前仅完成本地修改和验证,未进行线上发布。此前新增的即时归档回调若尚未启用,仍按 [聊天归档上线说明](../im-chat-history/README.md) 配置。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 腾讯 IM 账号存在性检查
|
||||
|
||||
## 官方契约
|
||||
|
||||
已核对[腾讯云查询账号文档](https://cloud.tencent.com/document/product/269/38417)及[国际站中文文档](https://intl.cloud.tencent.com/zh/document/product/1047/34956):
|
||||
|
||||
- `account_check` 为只读查询,单次最多 100 个账号。
|
||||
- 请求为 `CheckItem` 数组,每项包含 `UserID`。
|
||||
- 响应 `ResultItem` 每项包含 `UserID`、`ResultCode`、`ResultInfo`、`AccountStatus`;不是 `CheckResult` 字段。
|
||||
- 只有 `ResultCode = 0` 且状态为 `Imported` / `NotImported`,才能判断已导入 / 未导入。非零结果码表示检查失败,不能当作未导入。
|
||||
|
||||
## 实现
|
||||
|
||||
`server/app/common/service/TencentImService.php` 新增:
|
||||
|
||||
```php
|
||||
public function checkAccounts(array $accounts): array
|
||||
// ['existing' => string[], 'missing' => string[]]
|
||||
```
|
||||
|
||||
复用 `project.trtc` 配置和管理员签名,仅请求 `im_open_login_svc/account_check`,timeout 为 15 秒。单次方法调用最多发出一次 HTTP;上层负责分批和调度,方法内没有重试或账号导入。
|
||||
|
||||
输入必须为非空字符串,去重后不超过 100 个;超出限制在请求前抛错。空输入直接返回两个空数组。返回列表按原始输入顺序排列,不依赖云端结果顺序。
|
||||
|
||||
整批完整成功后才返回分类。顶层错误、单项错误、无效状态、结果遗漏、重复账号或未请求账号均抛出 `RuntimeException`。云端 `ErrorInfo/ErrorCode` 或 `ResultInfo/ResultCode` 保留为异常消息和 `getCode()`;网络异常同样不会转成缺失账号或空成功结果。
|
||||
|
||||
## 验证
|
||||
|
||||
- `php server/tests/TencentImAccountCheckTest.php` → `TENCENT_IM_ACCOUNT_CHECK_TEST_OK`
|
||||
- `php server/tests/ImRoamMessagePagerTest.php` → `IM_ROAM_MESSAGE_PAGER_TEST_OK`
|
||||
- 服务及新增测试的 `php -l`:通过。
|
||||
- 服务 `git diff --check`:通过。
|
||||
|
||||
新增测试覆盖官方请求字段、管理员配置、只读端点、15 秒 timeout、空输入、100/101 边界、去重和结果排序、批次/单项错误、网络错误及错误码保留、非法和不完整响应。HTTP 全程由替身截获,任何导入调用都会令测试失败。
|
||||
|
||||
未请求真实腾讯云、未导入任何账号、未访问或修改业务数据库;未修改 DiagnosisLogic、同步 Session 或前端。本子任务未提交。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 挂号列表按类型筛选
|
||||
|
||||
## 变更
|
||||
|
||||
- 仅在截图对应的 `admin/src/views/consumer/prescription/guahao.vue` 筛选区新增“挂号类型”下拉:全部、视频问诊、图文问诊。
|
||||
- 类型通过现有查询和分页请求提交;点击“查询”回到第一页,“重置”同时清除类型及原有筛选条件。
|
||||
- `server/app/adminapi/lists/doctor/AppointmentLists.php` 新增共享 `applyAppointmentTypeFilter`,列表、总数、状态 Tab 角标均应用相同类型过滤。计数不依赖先执行列表。
|
||||
- 全部、未传参数及 null 表示不按类型限制,保留历史电话等记录。视频筛选包括明确视频类型及按 `AppointmentTypeEnum::normalizeStored` 规则默认成视频的历史 null、空串、纯空白值。图文筛选仅匹配明确图文记录。
|
||||
- 非法筛选值(未知值、电话、错误大小写、带空格值、数字、布尔值、数组等)返回空结果及零计数,不默认成视频。存储的未知类型也不会由于 MySQL 默认大小写不敏感比较被当成视频。
|
||||
|
||||
## 验证
|
||||
|
||||
从 `D:/web/zyt/admin` 运行:
|
||||
|
||||
```text
|
||||
node --test tests/appointment-type-filter.test.cjs
|
||||
8 tests passed; 0 failures
|
||||
```
|
||||
|
||||
- `server/tests/AppointmentTypeFilterSqlFixture.php` 使用实际 `AppointmentLists` 和 ThinkPHP MySQL SQL builder 生成列表、count、状态分组 SQL。数据库连接方法被替换为抛错,测试不能连接业务库。
|
||||
- 测试在 Node 内存 SQLite 中执行生成的查询,仅将 MySQL `BINARY` 比较转换为 SQLite 等价的显式二进制排序。覆盖历史空值、未知/电话类型、软删除诊单、非法输入、类型与状态/医生/患者条件组合、分页及计数一致性。
|
||||
- 实际 Vue setup 与实际 `usePaging` 验证选择类型后查询的请求参数、回到第一页以及重置行为;SFC 脚本与模板编译通过。
|
||||
- PHP 语法检查及 `git diff --check` 通过。
|
||||
|
||||
未访问真实数据库或外部服务,未提交代码。前序挂号类型及 IM 聊天修改均保留;未修改其他界面或 `app/`。`.trellis/` 不存在。
|
||||
@@ -0,0 +1,495 @@
|
||||
vite v6.4.2 building for production...
|
||||
|
||||
WARN
|
||||
(!) outDir D:\web\zyt\artifacts\im-account-filter\admin-build is not inside project root and will not be emptied.
|
||||
Use --emptyOutDir to override.
|
||||
|
||||
|
||||
transforming...
|
||||
✓ 4063 modules transformed.
|
||||
|
||||
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
||||
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
../artifacts/im-account-filter/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
||||
../artifacts/im-account-filter/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ImChatRecordPanel-CNRZQxnD.css 1.85 kB │ gzip: 0.61 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-ma9pGSsz.css 8.04 kB │ gzip: 2.32 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/code-preview-DBRXIZ5s.js 0.16 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-BQzWQW42.js 0.18 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/refund-log-BKxGpbZJ.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/account-adjust-B9vE51aJ.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-BKAOFNqu.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-BRF9xP1V.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-C0v_tOz5.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-CU8EgS8j.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/perm-ReZ1LYgU.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderTimeDialog-CO8lEk6t.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/weapp-CSMt578I.js 0.20 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/GancaoSubmissionReconcileButton-DaOrcVD6.js 0.21 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-B9L9tFty.js 0.21 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-Cnhi669r.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-RsdNi0sG.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-BIVwVbdj.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CS1FpnKo.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CsLOq8cA.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-DFJuQUt4.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-C0NXOJbg.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-DsY37x6w.js 0.23 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/auth-CQMXVsq4.js 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-DVCS2-tX.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/relations-add-TgfEUFsn.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mobile-style-A6-Ytfdg.js 0.26 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-C7l-AQkg.js 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/cost-edit-FBwF0u_p.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yeji-edit-DZBTmpJN.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/404-CHX28Ct_.js 0.31 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/data-table-B2Ely9V2.js 0.31 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-edit-CuxIy3Cx.js 0.33 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/medicine-BKSr1vD6.js 0.35 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/consumer-CUp4ANbV.js 0.35 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/user-Bf1uVga9.js 0.37 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/role-BF3CqZnw.js 0.39 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useDictOptions-Dm28dNNo.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CxDFxkgf.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/admin-C8Vnlftq.js 0.42 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DT1XKlpO.js 0.44 kB │ gzip: 0.36 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pay-DcNRfvav.js 0.45 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/department-CpbHoJWl.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-fRjuc9D6.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-lHNz_rVU.js 0.47 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-CqNjJZSp.js 0.48 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/post-CwbznGRQ.js 0.49 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/overflow-B3CCpSAq.js 0.49 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/decoration-aYJgFVdU.js 0.50 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/message-CFGRf72h.js 0.50 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/403-CmUBOX6U.js 0.52 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-BQ_JwEY8.js 0.53 kB │ gzip: 0.38 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-CzKaNt79.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-CkhITkGu.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/add-nav-BSigI979.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DgGA7umv.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-set-BEKLkStG.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-DED_RFBQ.js 0.55 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/patient-10cOiqNf.js 0.56 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/fans-BuQxLAMK.js 0.60 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-B9aKQy30.js 0.60 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-6r7TVAax.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-AkWNVJlI.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Cb2RlgSQ.js 0.61 kB │ gzip: 0.41 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-hADFb-Qb.js 0.63 kB │ gzip: 0.43 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CnTFyK4a.js 0.63 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/link-BSeCb4pE.js 0.64 kB │ gzip: 0.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/asset-CzzFecaT.js 0.67 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DygM2blB.js 0.69 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useMediaSourceOptions-BbeqKRaz.js 0.70 kB │ gzip: 0.42 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BybcODyl.js 0.70 kB │ gzip: 0.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/website-CnBMCdv4.js 0.74 kB │ gzip: 0.24 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-ChKBqme9.js 0.76 kB │ gzip: 0.49 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/self_input_stats-D9U_aByK.js 0.79 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/decoration-img-C3rZgZnn.js 0.81 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/dict-Cpp_Mw13.js 0.81 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/code-SnwCPhUk.js 0.82 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-vVE2p2bn.js 0.84 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Cis0yx5N.js 0.86 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/error-Dv5eyRBh.js 0.89 kB │ gzip: 0.60 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/theme-picker-DOZzD0eg.js 0.92 kB │ gzip: 0.59 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/wx_oa-BpHzQG4c.js 1.04 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/article--pPmZn0N.js 1.07 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-IVCzoaL5.js 1.08 kB │ gzip: 0.61 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/rich_text-CsKSftnY.js 1.10 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/cache-DgX09o9S.js 1.13 kB │ gzip: 0.69 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CUJ5A7oy.js 1.21 kB │ gzip: 0.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/finance-DuFx0ydf.js 1.30 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pc-DkNtz_df.js 1.31 kB │ gzip: 0.78 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/doctor-D9Ckz5qF.js 1.32 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-3C4xDi6c.js 1.33 kB │ gzip: 0.78 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-DTJHaQ97.js 1.33 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-BBtVqQrt.js 1.37 kB │ gzip: 0.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-zXgrFnfv.js 1.38 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/file-DgUwoHFQ.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/upload-zCiopoTX.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content-B0Nx2Xp7.js 1.42 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-D9DFyk1p.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bj-CUIgW.js 1.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_style_index_0_lang-DBzsCZdN.js 1.46 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-phone-zZyXZcXj.js 1.47 kB │ gzip: 0.80 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-DpFiCsnZ.js 1.50 kB │ gzip: 0.76 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-CO308tu7.js 1.55 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientInfoCard-CGWScS-V.js 1.60 kB │ gzip: 0.82 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-D1E0COys.js 1.62 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/style-CLznS8nw.js 1.62 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-B_lDKpjw.js 1.66 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/statistics-f_KiXaUV.js 1.69 kB │ gzip: 1.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dd6cWsZ6.js 1.71 kB │ gzip: 0.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/order-D_FCirhb.js 1.74 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/environment-B_XTqIWk.js 1.74 kB │ gzip: 0.74 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/setup-DgUzoDWf.js 1.77 kB │ gzip: 1.00 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useMenuOa-DLMZQo_g.js 1.82 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-setting-Cs0gMOw0.js 1.88 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Cy7DHPGZ.js 1.90 kB │ gzip: 0.99 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-CkPUppPd.js 1.94 kB │ gzip: 1.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bp595dP4.js 2.03 kB │ gzip: 0.98 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-7aWdYRG3.js 2.04 kB │ gzip: 1.14 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/TrackingNoteTimeline-CrRi6X48.js 2.08 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CdAj-Aii.js 2.09 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Ms8VOaHv.js 2.09 kB │ gzip: 0.95 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/open_setting-Dxyl8ZOh.js 2.10 kB │ gzip: 1.12 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BNJ0ZVyH.js 2.12 kB │ gzip: 1.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-DTPZREhE.js 2.16 kB │ gzip: 1.12 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/filing-D4NTcggm.js 2.17 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BucNbXmG.js 2.18 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/icon-Do497UAk.js 2.23 kB │ gzip: 0.82 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-C0NsmCvz.js 2.24 kB │ gzip: 1.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BnPsXfZU.js 2.28 kB │ gzip: 1.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CymXj7F7.js 2.29 kB │ gzip: 1.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/RecordingPlaybackBlock-AeIjtk_8.js 2.40 kB │ gzip: 1.19 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-DmZ0-3eO.js 2.40 kB │ gzip: 1.34 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/stats-nEmR8lop.js 2.45 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-2DOwK9aa.js 2.51 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-attr-B1hCdPcf.js 2.51 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DIEkzadS.js 2.52 kB │ gzip: 1.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/h5-BuxdT1Ge.js 2.56 kB │ gzip: 1.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/change-password-CTHR5iAx.js 2.57 kB │ gzip: 1.35 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-B_WQB6lM.js 2.61 kB │ gzip: 1.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tabbar-C1-HGN3_.js 2.66 kB │ gzip: 1.33 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/file-Ctv52jTJ.js 2.73 kB │ gzip: 1.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CQlLYE6x.js 2.74 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-CVFb32gJ.js 2.77 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-iYtuB3lb.js 2.77 kB │ gzip: 1.08 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/CaseRecordList-tMfop0Ax.js 2.80 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DPF_SVr9.js 2.81 kB │ gzip: 1.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bs1CWyht.js 2.85 kB │ gzip: 1.34 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/follow_reply-CLLbFSdU.js 2.85 kB │ gzip: 1.49 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DatyJB_Z.js 2.86 kB │ gzip: 1.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-C4-qihtL.js 2.86 kB │ gzip: 1.35 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DCzKMVHe.js 2.89 kB │ gzip: 1.41 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/default_reply-CCbjFiSS.js 2.91 kB │ gzip: 1.54 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-YlyRN4kl.js 2.96 kB │ gzip: 1.14 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/protocol-BzB8ADIy.js 2.98 kB │ gzip: 1.10 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-C14Yv8Im.js 2.99 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-node-B0ePzrLt.js 3.03 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-p4eU2-OM.js 3.04 kB │ gzip: 1.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-vT5soSYR.js 3.05 kB │ gzip: 1.50 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AssignLogPanel-CFZgXVh2.js 3.06 kB │ gzip: 1.48 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/keyword_reply-BdymY522.js 3.11 kB │ gzip: 1.60 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CC56Yug7.js 3.12 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BiWT4KtO.js 3.21 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-SMNg0bD8.js 3.25 kB │ gzip: 1.47 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-C_CMPELM.js 3.26 kB │ gzip: 1.40 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/pc_details-Dz3ryfVw.js 3.39 kB │ gzip: 1.49 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DzhB4nYz.js 3.39 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DaO3Dr3X.js 3.42 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-D9uB9rBL.js 3.43 kB │ gzip: 1.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-Bosm-6EE.js 3.44 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/balance_details-C4aiXjPv.js 3.48 kB │ gzip: 1.63 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-ClmX7Grr.js 3.48 kB │ gzip: 1.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CPBIijoV.js 3.52 kB │ gzip: 1.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/journal-D8rzfTjM.js 3.73 kB │ gzip: 1.46 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CZ-8SnyI.js 3.73 kB │ gzip: 1.57 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/detail-BtrR1i5l.js 3.76 kB │ gzip: 1.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AssistantWatchCallDialog-WFQy_w1O.js 3.78 kB │ gzip: 1.85 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/CallRecordPanel-C6S8lX6p.js 3.78 kB │ gzip: 1.77 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-BqH5-oDz.js 3.91 kB │ gzip: 1.71 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/preview-pc-Xf5C6ZTV.js 3.98 kB │ gzip: 1.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/AppointmentRecordPanel-Dz-s2ev7.js 4.02 kB │ gzip: 1.73 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C_uAPVti.js 4.11 kB │ gzip: 1.65 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CfYI3w0V.js 4.11 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/first_visit-CL3LsNli.js 4.13 kB │ gzip: 0.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/detail-BKl3pxSr.js 4.15 kB │ gzip: 1.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/MessageBubble-Bdhrgfj-.js 4.17 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-LDzctaXO.js 4.22 kB │ gzip: 1.92 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/useListTimeFilter-CH6UBG2R.js 4.26 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DRzquyS9.js 4.37 kB │ gzip: 2.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/bind-work-wechat-CROd0N9y.js 4.40 kB │ gzip: 2.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/recharge_record-enX6gukh.js 4.43 kB │ gzip: 1.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-rTwl4NKT.js 4.45 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DfAjN1ed.js 4.47 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DztYvhgb.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Crm6rZwf.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/preview-B82ShBae.js 4.49 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CNaaHiYx.js 4.51 kB │ gzip: 1.74 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/RecordingVideoPlayer-DjZKFdeH.js 4.53 kB │ gzip: 2.26 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CGc_5of6.js 4.55 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/paiban-PtPXBv3F.js 4.56 kB │ gzip: 2.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DD7jgAVQ.js 4.57 kB │ gzip: 1.33 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/login_register-BNOOfIjY.js 4.59 kB │ gzip: 2.01 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/dept-tongji-Dinbag9H.js 4.66 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DqeWJjfV.js 4.76 kB │ gzip: 2.05 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CHccJy1G.js 4.92 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CzQiz2K9.js 5.04 kB │ gzip: 1.99 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Ddc6tfnt.js 5.18 kB │ gzip: 2.03 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PaibanPanel-CrkOl1_i.js 5.28 kB │ gzip: 2.43 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-gYY-Ihui.js 5.32 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BQCUmdVA.js 5.53 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-ClqsT4Lq.js 5.55 kB │ gzip: 2.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-C_B8uAfu.js 5.56 kB │ gzip: 1.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BmffPg5D.js 5.59 kB │ gzip: 1.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ExerciseRecordList-BObPeBus.js 5.62 kB │ gzip: 2.16 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/attr-Dzwjk7L0.js 5.62 kB │ gzip: 2.24 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tongji-CX_AZXQC.js 5.66 kB │ gzip: 2.31 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientOrderList-BHvkaD04.js 5.82 kB │ gzip: 2.83 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DietRecordList-COIYJ7tp.js 5.84 kB │ gzip: 2.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-card-C2KWHSmH.js 5.91 kB │ gzip: 2.38 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/login-BQTyEEgd.js 6.03 kB │ gzip: 2.72 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/refund_record-B1htcVBe.js 6.27 kB │ gzip: 2.33 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DiagnosisTodoList-CIiI2vXk.js 6.34 kB │ gzip: 2.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DBgVsArC.js 6.35 kB │ gzip: 3.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/information-H4cElfMD.js 6.36 kB │ gzip: 1.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/NoteTimeline-D2Cn-t49.js 6.48 kB │ gzip: 2.60 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/account_cost-D_MeM5Ko.js 6.56 kB │ gzip: 2.86 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PatientCaseCard-DKT_4Y7L.js 6.61 kB │ gzip: 2.34 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Cz1l9OWu.js 6.61 kB │ gzip: 2.62 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-CHjcZeLZ.js 6.77 kB │ gzip: 2.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/readonly-DUZeV_w4.js 6.98 kB │ gzip: 2.69 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/picker-BmZLoYfG.js 7.32 kB │ gzip: 3.18 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BLYfnDac.js 7.35 kB │ gzip: 2.66 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/setting-rhpIbXGO.js 7.35 kB │ gzip: 2.93 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/weapp-9bAJJTz2.js 7.39 kB │ gzip: 2.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-kR5rYZYe.js 7.65 kB │ gzip: 3.13 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/config-3N1TQJj9.js 7.77 kB │ gzip: 2.59 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/TrackingMatrix-B607GGU9.js 7.84 kB │ gzip: 3.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/patient-call-XoEtFLU4.js 7.85 kB │ gzip: 3.24 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-BxkGCzTr.js 9.00 kB │ gzip: 2.83 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tcm-ChtuqNhm.js 9.44 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Fl6nmxEe.js 9.49 kB │ gzip: 3.85 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/BloodRecordList-BIMdWKO9.js 9.58 kB │ gzip: 3.33 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/medicine-tIfSC35q.js 9.74 kB │ gzip: 3.56 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-DA50wNyU.js 9.99 kB │ gzip: 3.55 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-C26qtC74.js 10.06 kB │ gzip: 3.98 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-eQPLcJEP.js 10.33 kB │ gzip: 3.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiBatchGenerateDialog-Bh8cBZ2U.js 10.36 kB │ gzip: 4.29 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/SendPanel-Nh2wEP27.js 10.37 kB │ gzip: 3.57 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ImChatRecordPanel-CY_60n0E.js 10.42 kB │ gzip: 4.54 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/progress-DuOm10jY.js 11.18 kB │ gzip: 4.59 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list-CVyT8pTR.js 11.18 kB │ gzip: 4.03 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BbWzj1v3.js 11.30 kB │ gzip: 4.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/appointment-Cs4RxO0A.js 11.34 kB │ gzip: 4.24 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-BLc8zvwf.js 11.44 kB │ gzip: 3.76 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/OrderPanel-BrM0G-6H.js 11.74 kB │ gzip: 4.22 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/picker-MDXg6BK8.js 12.15 kB │ gzip: 4.49 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/ProgressPanel-BdB2pJX6.js 12.58 kB │ gzip: 4.48 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/prescription-drawer-BWDDYnw4.js 12.76 kB │ gzip: 3.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BbU9WSws.js 13.00 kB │ gzip: 4.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DCXj6gvc.js 13.11 kB │ gzip: 3.91 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/h5-CaqOq4yE.js 13.83 kB │ gzip: 4.41 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BCAQC4rg.js 14.26 kB │ gzip: 5.90 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/WecomFloatingWidgetBuilder-DMflyfHq.js 15.22 kB │ gzip: 6.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/guahao-BYCsWxhO.js 15.46 kB │ gzip: 4.47 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-B9EPXS4Q.js 15.62 kB │ gzip: 6.44 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-1kRmD1tW.js 15.90 kB │ gzip: 5.20 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-CltFqeY9.js 15.90 kB │ gzip: 4.70 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DhIFawvT.js 16.34 kB │ gzip: 6.06 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-Bg-fEUBq.js 16.35 kB │ gzip: 5.11 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-D_XrhTnj.js 16.60 kB │ gzip: 6.07 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiReportDialog-DFaqRfkq.js 17.15 kB │ gzip: 6.46 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index--Gnpl1A-.js 17.84 kB │ gzip: 6.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/roster-swtnbZqH.js 18.09 kB │ gzip: 5.72 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-txlKB4-2.js 18.19 kB │ gzip: 6.88 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PromotionAutomationForm-7RvW6GYj.js 18.23 kB │ gzip: 6.36 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DM2LKeNd.js 18.60 kB │ gzip: 5.23 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/WelcomeMessageEditor-s-2890d7.js 19.11 kB │ gzip: 6.95 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/OrderActionHost-DRYzGZA4.js 21.16 kB │ gzip: 6.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DXrt0yLp.js 21.91 kB │ gzip: 7.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/add-Dtdqf7rg.js 22.29 kB │ gzip: 5.36 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-IWiFfZkH.js 22.96 kB │ gzip: 8.02 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/DailyMatrix-Cs-u0Y_n.js 24.97 kB │ gzip: 7.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list_h5-Dd9_81WR.js 27.86 kB │ gzip: 9.43 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/qywx-CMKsxFl1.js 30.35 kB │ gzip: 10.01 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/list-BSx_1xK9.js 30.35 kB │ gzip: 9.74 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/commission-settlement-Bo-HY-oY.js 35.83 kB │ gzip: 9.75 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/edit-Bar3Z5T5.js 35.86 kB │ gzip: 9.03 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-B0VJhQYg.js 36.70 kB │ gzip: 11.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index_h5-n5_UHvfv.js 37.92 kB │ gzip: 11.61 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-BJi-R8ym.js 39.27 kB │ gzip: 14.01 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DzMN_XCD.js 40.10 kB │ gzip: 11.87 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderDetailDrawer-xamaMnut.js 48.77 kB │ gzip: 13.48 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-E1I45ddz.js 51.46 kB │ gzip: 15.17 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-C0h-ySsY.js 55.03 kB │ gzip: 15.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-B_S7y1Ml.js 69.54 kB │ gzip: 21.09 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/yeji-DL63xmk5.js 87.40 kB │ gzip: 23.27 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-DqAUwhoT.js 88.42 kB │ gzip: 25.66 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/order_list-hscOFw30.js 114.89 kB │ gzip: 31.81 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/order_list_h5-ArM4QkBN.js 139.58 kB │ gzip: 36.82 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/index-FUvp6yIk.js 290.22 kB │ gzip: 92.03 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
||||
../artifacts/im-account-filter/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
||||
|
||||
WARN
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
|
||||
✓ built in 1m 50s
|
||||
@@ -0,0 +1,40 @@
|
||||
# 患者聊天记录修复与上线说明
|
||||
|
||||
本次修改已在本地完成,未连接业务数据库、未修改腾讯控制台、未部署到线上。
|
||||
|
||||
## 修复内容
|
||||
|
||||
- 诊单聊天记录按真实 `patient_id` 汇总同患者历次诊单的归档,并再次校验消息两端账号。原来按 `diagnosis_id` 读取,但消息键全表唯一,旧诊单归档过的消息不会在新诊单重复插入,因此新诊单可能一直空白。
|
||||
- 页面先显示归档,同时逐页同步;同步过程中及完成后自动刷新,完成后等待 30 秒再次检查。切换患者、隐藏页面、卸载时隔离旧请求。读取或同步失败保留已有记录并显示原因。
|
||||
- 修正腾讯历史接口续页的 `MaxTime/LastMsgKey`,单次请求只拉一页。分别完成医生、患者两个视角,避免医生删除自己侧历史造成漏查。只有两侧全部落库才保存完整检查点,失败仍从旧检查点补拉。
|
||||
- 回调、漫游和重复请求使用一致消息键,并兼容已有旧键。多元素消息完整保存;再次拉到旧截断记录时补齐内容。写库失败不再被 `INSERT IGNORE` 或假异步成功掩盖。
|
||||
- 聊天窗口在会话更新时、打开期间每 15 秒及关闭时触发当前医生会话同步。服务端从登录身份确定医生账号,不接受浏览器上传的聊天正文。
|
||||
- 增加经过腾讯签名验证的发送后回调,成功消息即时归档;失败发送不当作成功聊天历史保存。
|
||||
- 定时补偿按患者去重、轮转。失败写入任务错误信息,并保留后续执行机会,避免一次网络抖动永久停掉补偿。
|
||||
|
||||
## 上线操作
|
||||
|
||||
1. 发布本次 `admin` 与 `server` 代码并按现有流程构建管理端、刷新服务端配置缓存。
|
||||
2. 确认已有 `zyt_tcm_im_chat_message` 归档表(首次安装脚本:`server/sql/1.9.20260507/add_tcm_im_chat_message_table.sql`)。本次不增加归档表字段,不需要搬迁或清空历史数据。
|
||||
3. 执行 `server/sql/1.9.20260909/fix_im_chat_archive_crontab.sql`,或在后台把现有 `sync_im_chat_archive` 参数改为 `--since-days=0 --limit=200`。保持原任务开启,避免创建重复任务。SQL 中表前缀按实际部署调整。
|
||||
4. 按 [callback.md](./callback.md) 配置服务端 `[IM] CALLBACK_TOKEN`、腾讯控制台 HTTPS 地址 `/api/im/messageNotify`、**发单聊消息之后回调**、鉴权及超时重试。这一步是后台未打开时实时归档的必要配置。
|
||||
5. 打开患者诊单的“聊天记录”,等待同步完成。首次补历史耗时取决于会话和消息数量,成功归档的记录会先显示。也可在服务端运行 `php think sync_im_chat_archive --diagnosis-id=<实际诊单ID>` 补指定患者历史。
|
||||
|
||||
上线验收:对一位测试患者分别发送文字、图片和文件;关闭聊天后确认诊单记录自动更新;同患者其他诊单应能看到同样记录;其他患者不得出现这些消息。关闭后台后从患者端发送消息,确认回调归档正常,之后重新打开能看见。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node --test admin/tests/im-chat-history.test.cjs admin/tests/im-chat-archive-trigger.test.cjs admin/tests/appointment-call-mode.test.cjs`:27 项通过,涵盖真实组件 setup、组合消息展开、刷新、旧请求隔离、同步失败、聊天触发和前序图文通话限制。
|
||||
- `php server/tests/ImChatArchiveTest.php`:实际归档逻辑、真实分页器与状态机通过严格内存模型/SQL替身验证,详见 [archive-tests.md](./archive-tests.md)。
|
||||
- `php server/tests/ImChatArchiveCommandTest.php`:真实命令失败向调度器传播,错误可见且可重试。
|
||||
- `php server/tests/ImRoamMessagePagerTest.php`、`php server/tests/ImCallbackTest.php`(81 项)、`php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php`:通过。
|
||||
- 修改的 PHP 语法检查及 `git diff --check -- admin server`:通过。
|
||||
- 管理端 Vite 构建通过;日志 [build.log](./build.log)。全项目 `vue-tsc` 仍有既有错误,日志 [typecheck.log](./typecheck.log);本次 IM 文件和聊天窗口无类型诊断。
|
||||
|
||||
只读复核提出的多元素截断、单侧历史遗漏和定时失败不可见均已修复并补测;[backend-review.md](./backend-review.md) 保留的是修复前发现记录。
|
||||
|
||||
## 历史数据边界
|
||||
|
||||
已归档的消息会继续保存在本地;未归档历史只能补回腾讯云仍保留的部分。云端已过保留期或双方都已删除、且本地从未归档的消息无法凭代码恢复。图片、文件当前保存的是云端链接,链接有效期仍受云端保留策略影响。[腾讯历史消息说明](https://cloud.tencent.cn/document/product/269/42794)
|
||||
|
||||
本轮不包含真实患者的线上端到端验收,以上测试均使用虚构数据和替身服务。
|
||||
@@ -0,0 +1,31 @@
|
||||
# IM 归档行为测试交付
|
||||
|
||||
新增 `server/tests/ImChatArchiveTest.php`,直接加载实际 `DiagnosisLogic`、`ImRoamMessagePager` 和 `ImChatSyncSession`。只替换模型、Db、Cache、时钟和腾讯服务传输层;没有改动业务实现。
|
||||
|
||||
## 覆盖
|
||||
|
||||
- 同患者新旧诊单读取同一归档,返回当前授权诊单 ID;其他患者和历史错误 `patient_id` 对应的异常账号记录不会混入。
|
||||
- callback 的 `MsgTime/MsgKey` 与 roam 的 `MsgTimeStamp/MsgRandom/MsgKey` 生成同一 canonical ID,重复到达不重复归档、不改变原诊单归属。
|
||||
- callback 必须有整数 `SendMsgResult`;发送失败跳过,缺失或非法类型抛错。
|
||||
- 旧 `seq_random_from` 键只在患者、双向账号、时间都一致时兼容。其他患者、错误账号或不同时间的旧键碰撞不会覆盖原记录,也不会压掉正确的新消息。
|
||||
- 多元素消息保存 text/image/file 完整顺序并读回 `parts`;匹配的旧截断消息修复内容但保留旧键和原始诊单;重复修复幂等,其他患者不变,修复写库失败向上传播。
|
||||
- current 范围只使用当前管理员账号;token 精确绑定 admin/diagnosis/patient。换登录人、换诊单、换患者、非法或过期 token 都无法请求腾讯云或修改缓存进度。
|
||||
- 医生侧和患者侧分别完整分页,共享相同扫描下界并交换 operator/peer;共享消息幂等,患者侧独有记录仍入库。
|
||||
- 只有所有页面持久化且双方视角都成功,才写该医生/患者的完整 checkpoint;值为扫描开始时间。首次下界为 0,下次双方使用完整 checkpoint 减 120 秒。
|
||||
- 医生侧或患者侧失败都保留旧 checkpoint;医生侧失败仍尝试患者侧,下一轮从原下界补拉并去重。
|
||||
- 单页数据库失败不推进缓存游标,不写完成 checkpoint,原 token 可以原游标重试。81 条消息跨两个 SQL 批次时,第二批失败不会发布完成状态,下一轮补齐且不重复第一批 80 条。
|
||||
- callback 和 CLI 同步均不吞写库错误;纯 Session 的双侧切换、其他会话继续处理、失败可见及归档失败传播也有独立断言。
|
||||
|
||||
## 运行结果
|
||||
|
||||
```text
|
||||
php server/tests/ImChatArchiveTest.php
|
||||
IM_CHAT_ARCHIVE_TEST_OK
|
||||
|
||||
php -l server/tests/ImChatArchiveTest.php
|
||||
No syntax errors detected
|
||||
```
|
||||
|
||||
测试不会初始化 ThinkPHP 应用环境。模型和 SQL 使用严格内存替身,意外表、SQL 及更新字段会失败;腾讯服务只消费预置响应,没有网络能力。未访问业务数据库、未执行迁移、未产生真实回调或消息。
|
||||
|
||||
本测试验证业务方法与分页/持久化边界,不替代真实 MySQL 部署验收。未发现需要额外修改的业务问题,未提交。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 聊天归档后端只读复核
|
||||
|
||||
检查范围:DiagnosisLogic 的回调、消息键、持久化、患者历史读取、分页与批量同步,DiagnosisController 的读取/触发接口,ImChatSyncSession、ImRoamMessagePager、SyncImChatArchive、定时 SQL 与实际 Crontab 调度器,以及 IM callback 的鉴权入口。
|
||||
|
||||
以下为检查时仍存在的三个实质问题。未修改业务代码。
|
||||
|
||||
## 1. [P1] 多元素消息只保存第一个元素,形成不可恢复的内容缺失
|
||||
|
||||
位置:[DiagnosisLogic.php](/D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1381),`parseTimMsgBody`;同文件 `persistImChatArchiveRows` 与 `insertIgnoreImChatBatch`。
|
||||
|
||||
`parseTimMsgBody` 遇到首个文本、图片或其他已识别元素就 `return $out`。因此合法的 `MsgBody=[text1,text2]` 只归档 text1,`[text,image]` 则丢失图片。数据库行也没有保存原始 MsgBody。新回调和历史补拉都复用这条解析路径;稍后即使再次取回完整消息,相同 msg_id 的 `ON DUPLICATE KEY UPDATE msg_id=msg_id` 也不会补齐内容。
|
||||
|
||||
用真实 `normalizeTimMessage` 的 Reflection 调用作了无数据库复现:两段 TIMTextElem 分别为 `first fragment` 与 `second fragment`,输出 `text=first fragment`,没有原始消息体字段。官方明确单条消息可包含多个消息元素。[腾讯消息格式说明](https://cloud.tencent.cn/document/product/269/2720)
|
||||
|
||||
建议保留全部消息元素或原始 JSON,再由展示层处理;并处理已有不完整归档的补齐,不能只靠重复键忽略。此问题源于已有解析函数,但新增即时归档仍沿用它,未实现完整内容留存。
|
||||
|
||||
## 2. [P2] 定时补拉失败被调度器清成正常状态
|
||||
|
||||
位置:[SyncImChatArchive.php](/D:/web/zyt/server/app/common/command/SyncImChatArchive.php:49),[Crontab.php](/D:/web/zyt/server/app/common/command/Crontab.php:75)。
|
||||
|
||||
同步返回错误时,命令写日志后 `return 1`。但 SQL 所注册任务实际由 `Crontab::start` 调用 `Console::call`:本项目 ThinkPHP `Console::call` 执行 `Command::run` 后没有读取退出码,只返回 Output;`Crontab::start` 也没有读取输出,紧接着无条件把数据库任务 `error` 清为空字符串。只要没有抛出异常,任务列表就不会反映腾讯请求或归档写库失败。
|
||||
|
||||
证据:[Console.php](/D:/web/zyt/server/vendor/topthink/framework/src/think/Console.php:217) 明确丢弃 `run` 返回值;[Crontab.php](/D:/web/zyt/server/app/common/command/Crontab.php:79) 仅以异常作为错误分支。实际结果是 CLI 单独执行可见非零退出码,已部署的数据库定时任务却仍显示正常,历史缺口容易持续被忽略。
|
||||
|
||||
建议让该命令与调度器明确传递失败状态并持久化错误,同时保持所需的后续重试安排;仅返回 1 不足以修复这条调度链。
|
||||
|
||||
## 3. [P2] 医生单侧漫游结果被当作整个会话的完整归档
|
||||
|
||||
位置:[DiagnosisLogic.php](/D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1084),`advanceImChatSync` 的 `nextPage` 调用与完整检查点写入。
|
||||
|
||||
所有补拉固定使用 doctor_* 作为 `Operator_Account`、patient_* 作为 `Peer_Account`。医生侧返回 Complete=1 后即建立该患者/医生的完整检查点,后续仅从检查点前两分钟重叠补拉,整个流程没有切换患者侧查询。
|
||||
|
||||
腾讯官方说明:任一侧清空或删除会话、删除部分消息,以及 REST 发送时 SyncOtherMachine=2,都会使双方能拉到的历史不同。因此,在首次补历史时,医生侧已经删除但患者侧仍保留的消息会被完全遗漏;医生侧 Complete=1 只证明该侧记录拉完,不能证明患者会话全部归档。新回调不能追溯部署前已发生的消息。[腾讯历史消息接口说明](https://cloud.tencent.cn/document/product/269/42794)
|
||||
|
||||
建议分别完成双方视角的补拉后再认定完整,使用现有 msg_id 去重;或至少按视角分别记录检查点,并对只完成一侧的结果保留准确语义。
|
||||
|
||||
## 已发现并在复核过程中修复的项目
|
||||
|
||||
`SendMsgResult` 非零消息原先同样落入普通聊天历史。官方明确发送失败仍触发发送后回调,0 表示成功、非 0 表示失败。[腾讯发送后回调说明](https://intl.cloud.tencent.com/zh/document/product/1047/34365)
|
||||
|
||||
复读当前代码已看到 `archiveImCallbackMessage` 校验整数 SendMsgResult、非零返回 0;此项已解决,不计入上述未解决问题。
|
||||
|
||||
## 未发现确证问题的检查项与验证边界
|
||||
|
||||
- 回调与漫游消息使用相同 from/to/MsgKey 时,实际 normalize 结果 msg_id 一致;旧 seq_random_from 键复用前会核对患者、双向账号与消息时间,没有发现可证实的跨患者键复用。
|
||||
- 读取/触发控制器先检查诊单可读权限;读取以真实 patient_id 汇总并再次核对双方 IM 账号;同步 token 绑定诊单、患者及管理员。
|
||||
- 同步失败会话不写完整检查点;归档抛异常时页游标不推进。没有发现当前状态机可确定复现的无限续页。
|
||||
- 批量查询按 patient_id 分组选择 MAX(id),对静态患者集合可按游标遍历并回绕。检查了已安装 ThinkORM 的 column/字段解析实现,未发现此处可确证的生产 SQL/ORM 语法错误;未连接 MySQL 执行。
|
||||
- `php server/tests/ImRoamMessagePagerTest.php` 通过;`php server/tests/ImCallbackTest.php` 的 81 项断言通过。两者均使用虚构配置和替身,无业务数据库或腾讯 API 调用。
|
||||
- 附加执行了真实消息归一化函数的无数据库复现,确认多元素内容丢失及 callback/roam 消息键相同。
|
||||
|
||||
本报告是只读复核;唯一新增文件为本报告。
|
||||
@@ -0,0 +1,495 @@
|
||||
vite v6.4.2 building for production...
|
||||
|
||||
WARN
|
||||
(!) outDir D:\web\zyt\artifacts\im-chat-history\admin-build is not inside project root and will not be emptied.
|
||||
Use --emptyOutDir to override.
|
||||
|
||||
|
||||
transforming...
|
||||
✓ 4063 modules transformed.
|
||||
|
||||
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
||||
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
../artifacts/im-chat-history/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
||||
../artifacts/im-chat-history/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ImChatRecordPanel-DUX4tYwU.css 1.85 kB │ gzip: 0.61 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-D4TKAF49.css 8.04 kB │ gzip: 2.32 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/code-preview-CRYwsie5.js 0.16 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-CnxBj2N-.js 0.18 kB │ gzip: 0.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/refund-log-D0z9hzwY.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/account-adjust-D_iiZRxf.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-GwXp2MfM.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-V0f1_vdW.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-DtM3yPIw.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-Ba0l8P4X.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/perm-CiI_vrea.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderTimeDialog-BHoWMYDV.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/weapp-Iyi9vyFf.js 0.20 kB │ gzip: 0.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/GancaoSubmissionReconcileButton-CCqEzOaM.js 0.21 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-CNCHtrEi.js 0.21 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-B0tfK27i.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-zzzTd8tZ.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-cLpVnAEl.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-BFh3g8dC.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-CBF3zoKz.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-BwLHbRaK.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-r5keepdo.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DTpkRNnx.js 0.23 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/auth-D69rZ0QF.js 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DKHY8c-x.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/relations-add-eMBobvC6.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mobile-style-M0fJyJft.js 0.26 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-Dl6ZYHd6.js 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/cost-edit-HlyKgBPk.js 0.30 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yeji-edit-DiKvabrc.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/404-Bx67M9_i.js 0.31 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/data-table-DiwFgvTm.js 0.31 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-edit-BJ1TaJrJ.js 0.33 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/medicine-ClhF6dhx.js 0.35 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/consumer-DvvPd1hy.js 0.35 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/user-BoFAWLZw.js 0.37 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/role-DursS8TF.js 0.39 kB │ gzip: 0.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useDictOptions-DGLBVObI.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-offYjbU6.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/admin-Dswuerz6.js 0.42 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-CMc73Fka.js 0.44 kB │ gzip: 0.35 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pay-XwSNNoxK.js 0.45 kB │ gzip: 0.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/department-BG0AgOKP.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-B2wXzt_G.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-BkJ1RaU_.js 0.47 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-Cy0U3F6F.js 0.48 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/post-CQN2ASdU.js 0.49 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/overflow-Ccm0BASr.js 0.49 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/decoration-qD9r-XC7.js 0.50 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/message-4zgGHQuK.js 0.50 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/403-NUKlpGTJ.js 0.52 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-DSCEzizm.js 0.53 kB │ gzip: 0.38 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-IKnWwccM.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-CbwBYdrP.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/add-nav-CKob9pi_.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-BPe9H2z1.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-set-Cdq4oYg2.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-B0hrD1LU.js 0.55 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/patient-x1uj0g5K.js 0.56 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/fans-Bko_8S6a.js 0.60 kB │ gzip: 0.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-DlaxhiVa.js 0.60 kB │ gzip: 0.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-S1Kh6jUC.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-BqqVaNlV.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DMxWOKx5.js 0.61 kB │ gzip: 0.41 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BhE11Nq_.js 0.63 kB │ gzip: 0.43 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DIWWWv4S.js 0.63 kB │ gzip: 0.32 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/link-gNOTfPzX.js 0.64 kB │ gzip: 0.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/asset-lYonyybX.js 0.67 kB │ gzip: 0.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-D1LuuMC-.js 0.69 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useMediaSourceOptions-fw385QtK.js 0.70 kB │ gzip: 0.42 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BfBIkn5E.js 0.70 kB │ gzip: 0.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/website-BKsM8bHj.js 0.74 kB │ gzip: 0.24 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-BNR0wsyZ.js 0.76 kB │ gzip: 0.49 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/self_input_stats-Bh9Ivy5F.js 0.79 kB │ gzip: 0.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/decoration-img-CHn5E-6-.js 0.81 kB │ gzip: 0.50 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/dict-C49DwoYN.js 0.81 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/code-DKMUKFzD.js 0.82 kB │ gzip: 0.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-BdQq_NlD.js 0.84 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CFOyeTvK.js 0.86 kB │ gzip: 0.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/error-4ClSi6LT.js 0.89 kB │ gzip: 0.60 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/theme-picker-M1AQ2XQj.js 0.92 kB │ gzip: 0.59 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/wx_oa-CsUXz3M_.js 1.04 kB │ gzip: 0.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/article-B_VIFIGv.js 1.07 kB │ gzip: 0.26 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-Cn7UpFyE.js 1.08 kB │ gzip: 0.62 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/rich_text-C8iWxnva.js 1.10 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/cache-BZx4Ny8O.js 1.13 kB │ gzip: 0.69 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-lM29fXUc.js 1.21 kB │ gzip: 0.70 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/finance-B4gS023c.js 1.30 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pc-BVO2bNRL.js 1.31 kB │ gzip: 0.78 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/doctor-3GXATh2M.js 1.32 kB │ gzip: 0.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C3xkO5fZ.js 1.33 kB │ gzip: 0.78 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-Db4P_VPl.js 1.33 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-gqAvW-3z.js 1.37 kB │ gzip: 0.70 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-DlEk_FSe.js 1.38 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/file-BOrBUJT2.js 1.41 kB │ gzip: 0.63 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/upload-DooW1Wwa.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content-CLc2dzGE.js 1.42 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-BN1CyAsN.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CSekGh8y.js 1.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_style_index_0_lang-BR-rP4B6.js 1.46 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-phone-B-GLqF9L.js 1.47 kB │ gzip: 0.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-Ch8kaUiR.js 1.50 kB │ gzip: 0.76 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-BwPV9PE0.js 1.55 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientInfoCard-BwfsUM2s.js 1.60 kB │ gzip: 0.82 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-D7iG4611.js 1.62 kB │ gzip: 0.71 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/style-BSaMeNII.js 1.62 kB │ gzip: 0.88 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-BNZvq8OU.js 1.66 kB │ gzip: 0.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/statistics-D4bqVAIO.js 1.69 kB │ gzip: 1.02 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BucqEaGC.js 1.71 kB │ gzip: 0.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/order-D6xPdSYq.js 1.74 kB │ gzip: 0.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/environment-FBaSCNXw.js 1.74 kB │ gzip: 0.74 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/setup-xqfHpnLl.js 1.77 kB │ gzip: 1.00 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useMenuOa-C9nb3qNM.js 1.82 kB │ gzip: 0.85 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-setting-CSC9RHvg.js 1.88 kB │ gzip: 0.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CmqXvX0o.js 1.90 kB │ gzip: 0.99 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-Dj2cjLjD.js 1.94 kB │ gzip: 1.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-dsbqhbDF.js 2.03 kB │ gzip: 0.98 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-e3LZNL22.js 2.04 kB │ gzip: 1.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/TrackingNoteTimeline-B5IIeBTZ.js 2.08 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-irEiZ2to.js 2.09 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BTdpdc9s.js 2.09 kB │ gzip: 0.95 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/open_setting-CbyEUjYD.js 2.10 kB │ gzip: 1.12 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-D-oRNzvN.js 2.12 kB │ gzip: 1.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CIazW94a.js 2.16 kB │ gzip: 1.12 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/filing-BBoLgS0g.js 2.17 kB │ gzip: 1.17 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dy_DI6fL.js 2.18 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/icon-JBSUrucJ.js 2.23 kB │ gzip: 0.82 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-ef0Do_pT.js 2.24 kB │ gzip: 1.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-B04f-PF6.js 2.28 kB │ gzip: 1.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKpR0wBb.js 2.29 kB │ gzip: 1.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/RecordingPlaybackBlock-Cvp4VJaq.js 2.40 kB │ gzip: 1.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CSADBu_h.js 2.40 kB │ gzip: 1.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/stats-BwkMZNKj.js 2.45 kB │ gzip: 0.58 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-BdfIrilq.js 2.51 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-attr-I62kgtSB.js 2.51 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-D1CNqt95.js 2.52 kB │ gzip: 1.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/h5-DUfSYLLB.js 2.56 kB │ gzip: 1.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/change-password-BHBSZ_1x.js 2.57 kB │ gzip: 1.35 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-TLGujWib.js 2.61 kB │ gzip: 1.26 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tabbar-BGzVO5aw.js 2.66 kB │ gzip: 1.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/file-CIbk4Ntr.js 2.73 kB │ gzip: 1.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CRrevJfY.js 2.74 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-CLtuslQR.js 2.77 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-B49nVddb.js 2.77 kB │ gzip: 1.08 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/CaseRecordList-CoSK0NHb.js 2.80 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CoDIyiGp.js 2.81 kB │ gzip: 1.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CsWG06Br.js 2.85 kB │ gzip: 1.34 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/follow_reply-rMkYpNN5.js 2.85 kB │ gzip: 1.49 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BIkjSt8G.js 2.86 kB │ gzip: 1.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-B8oLeJca.js 2.86 kB │ gzip: 1.35 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-By9ydUIZ.js 2.89 kB │ gzip: 1.41 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/default_reply-D6ouDPpq.js 2.91 kB │ gzip: 1.54 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Deve6q4X.js 2.96 kB │ gzip: 1.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/protocol-BFlJmc-k.js 2.98 kB │ gzip: 1.10 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DCtCwWvi.js 2.99 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-node-BaFPJoMU.js 3.03 kB │ gzip: 1.25 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-B0HRNpRz.js 3.04 kB │ gzip: 1.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-CusCTJJ4.js 3.05 kB │ gzip: 1.50 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AssignLogPanel-DRsSJqIX.js 3.06 kB │ gzip: 1.48 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/keyword_reply-7EuDiKuz.js 3.11 kB │ gzip: 1.60 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DJE0T4Q1.js 3.12 kB │ gzip: 1.51 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CeqsxLC-.js 3.21 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DYd7907D.js 3.25 kB │ gzip: 1.47 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-GLaRZwMy.js 3.26 kB │ gzip: 1.40 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/pc_details-Bw7VUj2g.js 3.39 kB │ gzip: 1.49 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CHQBJpbo.js 3.39 kB │ gzip: 1.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dj5Yh3rP.js 3.42 kB │ gzip: 1.53 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-B19ngLYq.js 3.43 kB │ gzip: 1.64 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-B20SThVv.js 3.44 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/balance_details-D_KfXFYa.js 3.48 kB │ gzip: 1.63 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-D7yxqlrs.js 3.48 kB │ gzip: 1.64 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BKPP98Vt.js 3.52 kB │ gzip: 1.56 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/journal-BQXRw-rc.js 3.73 kB │ gzip: 1.46 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-wbPWradd.js 3.73 kB │ gzip: 1.57 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/detail-DPEZNO-R.js 3.76 kB │ gzip: 1.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AssistantWatchCallDialog-Do4wtgqb.js 3.78 kB │ gzip: 1.85 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/CallRecordPanel-0Um7IKOD.js 3.78 kB │ gzip: 1.77 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-Bv_AGp7e.js 3.91 kB │ gzip: 1.71 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/preview-pc-vQFYLbHC.js 3.98 kB │ gzip: 1.63 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/AppointmentRecordPanel-C_BICu-f.js 4.02 kB │ gzip: 1.73 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-g-evO6wb.js 4.11 kB │ gzip: 1.65 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-fFppMfCb.js 4.11 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/first_visit-DrLCEbkH.js 4.13 kB │ gzip: 0.89 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/detail-DdD6RcHV.js 4.15 kB │ gzip: 1.56 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/MessageBubble-DEoXDLTv.js 4.17 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-F345h9xq.js 4.22 kB │ gzip: 1.92 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/useListTimeFilter-i-LKEU1K.js 4.26 kB │ gzip: 1.52 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CWKqi4D6.js 4.37 kB │ gzip: 2.14 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/bind-work-wechat-BV_lfAa8.js 4.40 kB │ gzip: 2.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/recharge_record-saR6x18Z.js 4.43 kB │ gzip: 1.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DuwLBHge.js 4.45 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dv70S3A7.js 4.47 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DJa1-cVO.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Tejy5EwF.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/preview-zbOEK628.js 4.49 kB │ gzip: 1.70 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Ba54tb4y.js 4.51 kB │ gzip: 1.75 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/RecordingVideoPlayer-B-dcy3dw.js 4.53 kB │ gzip: 2.26 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DRTC0exf.js 4.55 kB │ gzip: 1.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/paiban-pudpsS38.js 4.56 kB │ gzip: 2.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-C7KZ_Ik4.js 4.57 kB │ gzip: 1.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/login_register-CGqb90Lj.js 4.59 kB │ gzip: 2.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/dept-tongji-BOgCLB2J.js 4.66 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BpKwfCQA.js 4.76 kB │ gzip: 2.05 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CNcDtJ9x.js 4.92 kB │ gzip: 2.04 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-C1amYZ9l.js 5.04 kB │ gzip: 1.99 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CAgpj-eS.js 5.18 kB │ gzip: 2.03 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PaibanPanel-DZNODK98.js 5.28 kB │ gzip: 2.43 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DXb87qjA.js 5.32 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DkaxoKzQ.js 5.53 kB │ gzip: 2.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-CHwIUtxK.js 5.55 kB │ gzip: 2.56 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Bu0RgavZ.js 5.56 kB │ gzip: 1.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-B-q3QQVT.js 5.59 kB │ gzip: 1.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ExerciseRecordList-DZ-SHSRX.js 5.62 kB │ gzip: 2.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/attr-CRSc5FiL.js 5.62 kB │ gzip: 2.24 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tongji-CQw3u6Fc.js 5.66 kB │ gzip: 2.31 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientOrderList-DPhs-MJN.js 5.82 kB │ gzip: 2.84 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DietRecordList-DK7RU2Fo.js 5.84 kB │ gzip: 2.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-card-XFpSnNvn.js 5.91 kB │ gzip: 2.38 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/login-mU_6coFj.js 6.03 kB │ gzip: 2.72 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/refund_record-WGOu0QAJ.js 6.27 kB │ gzip: 2.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DiagnosisTodoList-DFhubbCI.js 6.34 kB │ gzip: 2.88 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BDHQ8kpu.js 6.35 kB │ gzip: 3.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/information-cQxR_gym.js 6.36 kB │ gzip: 1.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/NoteTimeline-C_UaNOKv.js 6.48 kB │ gzip: 2.60 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/account_cost-Bo5VIFr8.js 6.56 kB │ gzip: 2.86 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PatientCaseCard-B7o30YLL.js 6.61 kB │ gzip: 2.34 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DcDTRBFW.js 6.61 kB │ gzip: 2.62 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CVeY18TE.js 6.77 kB │ gzip: 2.89 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/readonly-Bt9YZALs.js 6.98 kB │ gzip: 2.69 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/picker-BAPV7c7C.js 7.32 kB │ gzip: 3.18 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dz5fUrfr.js 7.35 kB │ gzip: 2.66 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/setting-Hgl4zct0.js 7.35 kB │ gzip: 2.93 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/weapp-BvXTEjc6.js 7.39 kB │ gzip: 2.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BBTpOFL8.js 7.65 kB │ gzip: 3.13 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/config-CV0P9oRu.js 7.77 kB │ gzip: 2.59 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/TrackingMatrix-Av_7LhBk.js 7.84 kB │ gzip: 3.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/patient-call-Cg9Vaj8C.js 7.85 kB │ gzip: 3.24 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-da9KFXx_.js 9.00 kB │ gzip: 2.83 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ImChatRecordPanel-CsmMG9cI.js 9.30 kB │ gzip: 4.21 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tcm-BuqVT7wS.js 9.44 kB │ gzip: 1.79 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DALWbhiS.js 9.49 kB │ gzip: 3.85 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/BloodRecordList-_QjjAMCZ.js 9.58 kB │ gzip: 3.33 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/medicine-jP_YfcAn.js 9.74 kB │ gzip: 3.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-pzxY3-e8.js 9.99 kB │ gzip: 3.55 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DP7qunZa.js 10.06 kB │ gzip: 3.98 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-hTQz2IcE.js 10.33 kB │ gzip: 3.89 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiBatchGenerateDialog-CZ1Wvfc7.js 10.36 kB │ gzip: 4.29 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/SendPanel-CabEk305.js 10.37 kB │ gzip: 3.57 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/progress-BsQKnwsp.js 11.18 kB │ gzip: 4.59 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list-BHd-eM8G.js 11.18 kB │ gzip: 4.03 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index--2yxCiVu.js 11.30 kB │ gzip: 4.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/appointment-ByorQLwa.js 11.34 kB │ gzip: 4.24 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-Qv0aOhsU.js 11.44 kB │ gzip: 3.76 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/OrderPanel-B9ccVKPw.js 11.74 kB │ gzip: 4.22 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/picker-BwMDQMg3.js 12.15 kB │ gzip: 4.49 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/ProgressPanel-D3QNGTTf.js 12.58 kB │ gzip: 4.48 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/prescription-drawer-Dpvk2MZD.js 12.76 kB │ gzip: 3.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CgZP_eau.js 13.00 kB │ gzip: 4.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-gQ8YYUNH.js 13.11 kB │ gzip: 3.91 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/h5-DlI3J5NN.js 13.83 kB │ gzip: 4.40 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-_UZVnWu_.js 14.26 kB │ gzip: 5.90 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/guahao-DlzdlKLt.js 15.06 kB │ gzip: 4.44 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/WecomFloatingWidgetBuilder-BUV-hVOm.js 15.22 kB │ gzip: 6.02 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DYfZFMZH.js 15.62 kB │ gzip: 6.43 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-yIo8VvbW.js 15.90 kB │ gzip: 5.20 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-DyWYmoYJ.js 15.90 kB │ gzip: 4.71 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-nZMrOmT0.js 16.34 kB │ gzip: 6.06 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-COnlKS9k.js 16.35 kB │ gzip: 5.11 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DdycTSXR.js 16.60 kB │ gzip: 6.07 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiReportDialog-CRUja6D5.js 17.15 kB │ gzip: 6.47 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-9rPpXRqm.js 17.84 kB │ gzip: 6.02 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/roster-5q1rfTpI.js 18.09 kB │ gzip: 5.72 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dw8UNAuL.js 18.19 kB │ gzip: 6.88 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PromotionAutomationForm-BA0NIzNP.js 18.23 kB │ gzip: 6.37 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DL3Rz2aL.js 18.60 kB │ gzip: 5.23 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/WelcomeMessageEditor-CRBBUPRj.js 19.11 kB │ gzip: 6.95 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/OrderActionHost-B-jmhVnH.js 21.16 kB │ gzip: 6.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-C_so5ska.js 21.91 kB │ gzip: 7.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/add-EcOqBIiP.js 22.29 kB │ gzip: 5.36 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-PgI4nM1G.js 22.96 kB │ gzip: 8.02 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/DailyMatrix-HANRgYOy.js 24.97 kB │ gzip: 7.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list_h5-DoSV8WSE.js 27.86 kB │ gzip: 9.42 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/qywx-DffvrfUX.js 30.35 kB │ gzip: 10.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/list-QcqwAhrg.js 30.35 kB │ gzip: 9.73 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/commission-settlement-C2o50eF8.js 35.83 kB │ gzip: 9.75 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/edit-Drj7L2C8.js 35.86 kB │ gzip: 9.02 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-DUf7A4Dd.js 36.70 kB │ gzip: 11.81 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index_h5-B07JsZ1O.js 37.92 kB │ gzip: 11.61 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-BrMxtLph.js 39.24 kB │ gzip: 14.01 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CFzaCYqK.js 40.10 kB │ gzip: 11.87 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderDetailDrawer-D-NYIIFH.js 48.77 kB │ gzip: 13.48 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-CX4B8xwM.js 51.46 kB │ gzip: 15.16 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Cu5qMzfF.js 55.03 kB │ gzip: 15.89 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-gt2MSDnW.js 69.54 kB │ gzip: 21.09 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/yeji-Cw0Su2A3.js 87.40 kB │ gzip: 23.27 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Dz7niwME.js 88.42 kB │ gzip: 25.66 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/order_list-D_zw0l1b.js 114.89 kB │ gzip: 31.80 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/order_list_h5-CCe4ZA_s.js 139.58 kB │ gzip: 36.82 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/index-Cs3PThsC.js 290.22 kB │ gzip: 92.04 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
||||
../artifacts/im-chat-history/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
||||
|
||||
WARN
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
|
||||
✓ built in 1m 55s
|
||||
@@ -0,0 +1,62 @@
|
||||
# 腾讯 IM 单聊消息即时归档回调
|
||||
|
||||
接收地址为 `POST https://<业务域名>/api/im/messageNotify`。接口调用 `DiagnosisLogic::archiveImCallbackMessage(array $payload): int`,归档成功、幂等重复或业务层安全忽略均返回腾讯原生应答:
|
||||
|
||||
```json
|
||||
{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}
|
||||
```
|
||||
|
||||
归档抛异常时返回 HTTP 500 与 `ActionStatus=FAIL`,不会在写库失败时返回成功。错误响应和日志均不包含消息正文、签名、Token 或数据库异常内容。
|
||||
|
||||
## 部署配置
|
||||
|
||||
在服务端 `.env` 配置以下区段;鉴权 Token 应与腾讯 IM 控制台填写的值完全相同,以下内容仅为占位说明:
|
||||
|
||||
```ini
|
||||
[IM]
|
||||
CALLBACK_TOKEN = "在部署时填入独立生成的回调鉴权Token"
|
||||
```
|
||||
|
||||
加载路径是 `server/config/im.php` → `env('im.callback_token', '')`。本项目 ThinkPHP Env 也支持进程环境变量 `PHP_IM_CALLBACK_TOKEN`。Token 为空或只有空白时接口返回 HTTP 503,拒绝全部回调;不会降级为免签名。部署后按既有流程刷新配置缓存和 PHP 常驻进程。
|
||||
|
||||
应用 ID 与现有 `project.trtc.sdkAppId` 配置一致(来自既有 `[TRTC] SDK_APP_ID`)。只核对 IM/TRTC 应用 ID,无需把 TRTC SecretKey 放入回调 URL 或这个配置文件。
|
||||
|
||||
腾讯 IM 控制台进入当前应用的“回调配置”:
|
||||
|
||||
1. 填写上述 HTTPS 回调 URL,并开启回调。
|
||||
2. 勾选 **发单聊消息之后回调**,对应 `C2C.CallbackAfterSendMsg`。该事件可实时同步客户端或 REST API 单聊消息。[腾讯官方单聊回调文档](https://cloud.tencent.com/document/product/269/2716)
|
||||
3. 在回调 URL 的配置中开启鉴权,填写与 `[IM] CALLBACK_TOKEN` 相同的 Token。腾讯会追加 `Sign` 与 `RequestTime` 参数;签名算法为 `sha256(Token + RequestTime)`,时间偏差不得超过一分钟。[腾讯官方鉴权说明](https://cloud.tencent.cn/document/product/269/1522)
|
||||
4. 如需超时补投,开启事件发生之后回调的超时重试选项;这类回调默认不重试。官方默认回调超时为 2 秒,应监测归档耗时。失败回包不等同于已开启或保证重试,需结合控制台重试设置与历史拉取补偿。[腾讯官方回调超时说明](https://cloud.tencent.cn/document/product/269/1522)
|
||||
|
||||
部署只需新增接口与配置,不需要为患者添加登录 Token。自动控制器路由已支持 `/api/im/messageNotify`;API `InitMiddleware` 正常实例化控制器,`LoginMiddleware` 仅对 `ImController/messageNotify` 跳过用户会话查找,随后由控制器强制腾讯签名校验。其他 API 的登录要求不变。服务本身仍需满足项目原有安装与 HTTPS 入口条件。
|
||||
|
||||
## 接口校验与失败响应
|
||||
|
||||
- 只接受真实 POST 方法,不能通过方法覆盖头把 GET 伪装成 POST。
|
||||
- 正文最大 1 MiB;检查 Content-Length 及实际正文长度,JSON 必须是对象,最大解析深度 64。
|
||||
- `SdkAppid` 必须与配置一致;Sign 使用恒定时间 `hash_equals` 比较,签名时间窗口为正负 60 秒。
|
||||
- 请求正文必须有字符串 `CallbackCommand`;URL 中存在同名命令时必须完全相同。
|
||||
- 其他已经通过鉴权、命令一致的回调返回 OK 并忽略,不会进入患者归档。
|
||||
- HTTP 400 表示 JSON 或命令无效,403 表示鉴权失败,405 表示请求方法错误,413 表示正文超限,503 表示鉴权配置缺失,500 表示归档失败。
|
||||
|
||||
网关或 Web 服务器访问日志应对这个路径省略查询参数,避免默认 `$request_uri` 日志记录 URL 中的 `Sign`;应用代码只输出固定归档失败标记。正文内的患者/医生匹配与幂等规则由 `archiveImCallbackMessage` 负责。
|
||||
|
||||
## 虚构 curl 示例(未执行)
|
||||
|
||||
以下域名使用保留的 `.invalid` 后缀,Token、账户、消息和应用 ID 全是虚构值,只演示参数形状。不要将此示例直接指向生产环境。
|
||||
|
||||
```sh
|
||||
DEMO_TOKEN='fictional-example-token'
|
||||
REQUEST_TIME=$(date +%s)
|
||||
SIGN=$(printf '%s' "${DEMO_TOKEN}${REQUEST_TIME}" | sha256sum | cut -d ' ' -f 1)
|
||||
curl --request POST \
|
||||
"https://im-callback.example.invalid/api/im/messageNotify?SdkAppid=1400000000&CallbackCommand=C2C.CallbackAfterSendMsg&RequestTime=${REQUEST_TIME}&Sign=${SIGN}" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"CallbackCommand":"C2C.CallbackAfterSendMsg","From_Account":"patient_1001","To_Account":"doctor_2001","MsgSeq":7,"MsgRandom":8,"MsgTime":1700000000,"MsgKey":"7_8_1700000000","SendMsgResult":0,"MsgBody":[{"MsgType":"TIMTextElem","MsgContent":{"Text":"虚构测试消息"}}]}'
|
||||
```
|
||||
|
||||
## 验证结果
|
||||
|
||||
`php server/tests/ImCallbackTest.php`:81 项断言通过,使用真实控制器、ThinkPHP Request/Json 与 LoginMiddleware,归档逻辑、配置和日志由内存替身替代。覆盖官方签名样例、正负一分钟边界、缺失配置、错误 App ID、伪造签名、命令一致性、JSON 结构、正文上限、无登录回调、写库及日志失败、幂等返回 0,以及其他 action 不能借用回调免登录分支。
|
||||
|
||||
新增控制器、签名服务、配置、中间件和测试 PHP 语法检查通过;修改文件 `git diff --check` 通过。测试使用虚构配置,不加载真实 `.env`,未连接业务数据库,也没有调用腾讯 API 或修改腾讯控制台。
|
||||
@@ -0,0 +1,15 @@
|
||||
✔ type labels and video capability preserve legacy video while rejecting text and unknown (1.042ms)
|
||||
✔ rendered real chat template keeps text tools while excluding call controls in text mode (201.7263ms)
|
||||
✔ text mode hides all live call entries and refuses direct group invocation (52.6597ms)
|
||||
✔ all SDK outgoing paths stop before touching SDK when current appointment is text (50.1941ms)
|
||||
✔ video preflight uses actual appointment and prevents a server-side change to text (44.9134ms)
|
||||
✔ historical phone is audio-only; missing server confirmation never enables video (45.8624ms)
|
||||
✔ singleton call guard switches to current chat and cancels a pending old context (2.7073ms)
|
||||
ℹ tests 7
|
||||
ℹ suites 0
|
||||
ℹ pass 7
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1045.4102
|
||||
@@ -0,0 +1,29 @@
|
||||
# IM chat history frontend
|
||||
|
||||
## Changes
|
||||
|
||||
- `admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue` now displays archived messages immediately, starts synchronization automatically, displays real progress, and distinguishes empty history from read errors, sync failures, and incomplete synchronization. Existing rows remain visible during refresh and after failures. Success notifications occur only after a manually requested sync completes and the archive reload succeeds.
|
||||
- `admin/src/utils/im-chat-history.ts` owns the asynchronous lifecycle. It continues with `sync_token` until `completed`, reloads the archive every three completed pages or after a page takes the elapsed refresh interval past two seconds, and always performs a fresh archive read after completion. The next automatic sync starts 30 seconds after the current run ends. Requests within one active diagnosis are coalesced; diagnosis identity plus generation checks reject stale responses. Unmount, page hiding, and keep-alive deactivation cancel the timer and invalidate pending work.
|
||||
- `admin/src/api/tcm.ts` exposes typed archive/progress results and the optional `scope: 'current'` sync request. Both history endpoints use a 30-second timeout, disabled retries, and disabled duplicate-request cancellation. They read the raw response envelope and preserve backend error messages instead of losing them through the default response transform; successful automatic requests do not trigger global success toasts.
|
||||
- `admin/tests/im-chat-history.test.cjs` executes the actual controller and actual compiled component setup with controlled asynchronous API responses and clocks.
|
||||
|
||||
## Verification
|
||||
|
||||
Executed from `D:/web/zyt/admin`:
|
||||
|
||||
```text
|
||||
node --test tests/im-chat-history.test.cjs
|
||||
15 tests passed; 0 failures
|
||||
```
|
||||
|
||||
Coverage includes initial archive display while cloud sync is pending, fresh read after sync completes before the initial read, continuation tokens, intermediate refresh by pages/time, patient switching, unmount invalidation, preservation of rows on read/sync failure, partial failures, missing progress tokens, manual-only success notifications, duplicate clicks, one non-overlapping 30-second timer, visibility/deactivation, raw API errors/options, SFC compilation, and real panel lifecycle wiring.
|
||||
|
||||
`git diff --check` passed for the frontend files. No business database, Tencent IM, or live API requests were used. Repository-wide build/type checks and browser verification remain with the parent task.
|
||||
|
||||
## Integration details
|
||||
|
||||
- Archive reads always send `{ diagnosis_id, only_archived: 1 }`.
|
||||
- Panel synchronization starts with `{ diagnosis_id }`; subsequent requests add the returned `sync_token`. The panel intentionally uses the patient-wide scope; `scope: 'current'` is available for the parent's chat-dialog caller.
|
||||
- The panel treats `completed: true` with `error` or `errors` as a completed but incomplete sync, refreshes successful messages, and displays the accumulated errors.
|
||||
- Existing callers unmount the panel when switching away from the chat tab; additional hooks cover browser hiding and keep-alive route deactivation.
|
||||
- All prior appointment-mode changes were preserved. No other existing files were changed by this subtask. `.trellis/` is absent in this checkout.
|
||||
@@ -0,0 +1,24 @@
|
||||
# IM 漫游消息分页交付
|
||||
|
||||
## 官方契约
|
||||
|
||||
已核对[腾讯云拉取单聊历史消息文档](https://cloud.tencent.cn/document/product/269/42794)(文档更新时间 2026-05-22):续页必须把响应 `LastMsgTime` 作为下一次请求的 `MaxTime`,同时传递 `LastMsgKey`。`LastMsgTime` 不是请求字段。单页返回 `Complete = 0` 时仍需续拉。
|
||||
|
||||
## 实现和调用约定
|
||||
|
||||
- `server/app/common/service/TencentImService.php` 保留 `adminGetRoamMsg` 原七参签名;第七参非空时覆盖请求 `MaxTime`,不再发送 `LastMsgTime`。漫游单页 timeout 为 15 秒,其他请求的 timeout 默认值和行为未改。
|
||||
- 服务失败返回 `success = false`、`complete = 0`,并保留 `error` 和 `rawErrorCode`。即使 `ActionStatus = OK`,非零错误码也按失败处理;非法列表、分页字段和消息计数不会回退成空列表并成功完成。
|
||||
- 新增 `app\common\service\ImRoamMessagePager::nextPage(TencentImService $svc, string $operator, string $peer, array $cursor = []): array`。每次最多请求一页,不 sleep、不内部重试。
|
||||
- 成功返回 `msgList`(未经转换的消息数组)、`completed`(布尔值)及 `cursor`。游标字段为 `max_time`、`last_key`、`min_time`、`seen_keys`。首次 `min_time = 0`、`max_time = 4294967295`;不使用本地归档最大时间作为扫描下界。
|
||||
- `seen_keys` 保存当前 `max_time` 这一秒已经使用的游标键,下一页时间降低后重置。因此同秒多页可以继续,同秒 A→B→A 循环和直接重复会明确失败。上层应保存完整游标,并且仅在本页消息持久化成功后提交新游标。
|
||||
- 请求账号、消息双方账号、消息键/时间/内容类型、响应游标均校验;跨患者消息立即抛错。`Complete = 0` 却缺失有效时间/键、游标倒退到更新时刻或游标重复均不返回完成。
|
||||
- Pager 失败抛出 `RuntimeException`,云端 `error` 保留为异常消息,`rawErrorCode` 保留为异常 `getCode()`。上层保留本轮游标,并在后续步骤或下一轮重试。
|
||||
|
||||
## 验证
|
||||
|
||||
- `php server/tests/ImRoamMessagePagerTest.php`:通过,覆盖同秒多页、参数兼容、请求字段、15 秒 timeout、云端错误/错误码、网络错误、非法响应、缺失/重复/循环游标、跨患者消息、非法消息、请求游标和空会话完成。
|
||||
- 三个改动/新增 PHP 文件均通过 `php -l`。
|
||||
- 本子任务文件通过 `git diff --check`。
|
||||
- 测试不初始化应用环境,采用虚构配置、NullLogger 和覆盖 `httpPost` 的替身。未读取业务数据库、未真实请求腾讯云或其他外部服务。
|
||||
|
||||
本子任务没有修改 `DiagnosisLogic.php`、没有提交;接口集成由主代理负责。
|
||||
@@ -0,0 +1,109 @@
|
||||
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
|
||||
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
|
||||
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
|
||||
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1150,42): error TS2322: Type '(value: string[]) => void' is not assignable to type '(value: CascaderValue | null | undefined) => any'.
|
||||
Types of parameters 'value' and 'value' are incompatible.
|
||||
Type 'CascaderValue | null | undefined' is not assignable to type 'string[]'.
|
||||
Type 'undefined' is not assignable to type 'string[]'.
|
||||
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
|
||||
src/views/consumer/prescription/order_list.vue(253,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
|
||||
Type 'unknown' is not assignable to type 'TreeNodeData'.
|
||||
src/views/consumer/prescription/order_list.vue(983,45): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list.vue(1181,41): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(271,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
|
||||
Type 'unknown' is not assignable to type 'TreeNodeData'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(1550,45): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(1738,41): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
|
||||
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
|
||||
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/tongji.vue(94,58): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
src/views/doctor/tongji.vue(125,58): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/fans/h5.vue(43,47): error TS2322: Type '"" | "danger" | "info"' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
Type '""' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
src/views/fans/h5.vue(167,51): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
src/views/fans/index.vue(158,34): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
|
||||
src/views/message/notice/index.vue(12,44): error TS2322: Type '(opts?: { silent?: boolean; }) => Promise<any>' is not assignable to type '(name: TabPaneName) => any'.
|
||||
Types of parameters 'opts' and 'name' are incompatible.
|
||||
Type 'TabPaneName' is not assignable to type '{ silent?: boolean | undefined; } | undefined'.
|
||||
Type 'string' has no properties in common with type '{ silent?: boolean | undefined; }'.
|
||||
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/organization/department/edit.vue(20,29): error TS2353: Object literal may only specify known properties, and 'value' does not exist in type 'TreeOptionProps'.
|
||||
src/views/permission/admin/edit.vue(44,29): error TS2353: Object literal may only specify known properties, and 'value' does not exist in type 'TreeOptionProps'.
|
||||
src/views/tcm/appointment/list.vue(90,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
|
||||
Type 'unknown' is not assignable to type 'TreeNodeData'.
|
||||
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
|
||||
@@ -466,8 +466,7 @@ class DiagnosisController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 触发后台异步同步当前诊单的腾讯云 IM 聊天记录到本地归档表
|
||||
* 请求即返回,真正的同步逻辑在 fastcgi_finish_request 之后执行
|
||||
* @notes 同步一页 IM 历史并返回真实进度;客户端持 token 续拉,归档后即可展示。
|
||||
*/
|
||||
public function triggerImChatSync()
|
||||
{
|
||||
@@ -486,20 +485,18 @@ class DiagnosisController extends BaseAdminController
|
||||
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||
}
|
||||
|
||||
register_shutdown_function(function () use ($diagnosisId) {
|
||||
try {
|
||||
@set_time_limit(300);
|
||||
ignore_user_abort(true);
|
||||
DiagnosisLogic::syncImChatArchiveForDiagnosis($diagnosisId);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('triggerImChatSync failed', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->success('已发起后台同步,几秒后请重新加载查看', ['queued' => true]);
|
||||
try {
|
||||
@set_time_limit(30);
|
||||
$result = DiagnosisLogic::syncImChatArchiveStep(
|
||||
$diagnosisId,
|
||||
(int)$this->adminId,
|
||||
(string)$this->request->post('sync_token', ''),
|
||||
(string)$this->request->post('scope', '') === 'current'
|
||||
);
|
||||
return $this->data($result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('聊天记录同步失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,6 +108,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号类型筛选:空筛选表示全部;未知筛选值返回空结果,不套用旧记录的视频默认值。
|
||||
* 列表、总数与状态角标共用,历史空值按 normalizeStored 的规则归入视频。
|
||||
*/
|
||||
private function applyAppointmentTypeFilter($query): void
|
||||
{
|
||||
$type = $this->params['appointment_type'] ?? '';
|
||||
if ($type === '') {
|
||||
return;
|
||||
}
|
||||
if (!AppointmentTypeEnum::isWritable($type)) {
|
||||
$query->whereRaw('0 = 1');
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($q) use ($type): void {
|
||||
$q->whereRaw('BINARY a.appointment_type = :appointment_type_filter', ['appointment_type_filter' => $type]);
|
||||
if ($type === AppointmentTypeEnum::VIDEO) {
|
||||
// PHP trim 默认空白:空格、NUL、TAB、LF、VT、CR;只把完全为空白的旧值归入视频。
|
||||
$blankTypeSql = "TRIM(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(a.appointment_type, CHAR(0), ''),"
|
||||
. " CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(13), '')) = ''";
|
||||
$q->whereOrRaw('a.appointment_type IS NULL')->whereOrRaw($blankTypeSql);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
||||
*
|
||||
@@ -228,6 +254,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
|
||||
$this->applyChannelSourceFilter($query, $chFilter);
|
||||
|
||||
$this->applyAppointmentTypeFilter($query);
|
||||
|
||||
// 是否确认诊单:1=已确认 0=未确认
|
||||
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
||||
@@ -422,6 +450,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
|
||||
$this->applyChannelSourceFilter($query, $chFilter);
|
||||
|
||||
$this->applyAppointmentTypeFilter($query);
|
||||
|
||||
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
||||
$sf = $this->params['status'] ?? '';
|
||||
if ($sf === '' || (int) $sf !== 2) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\enum\AppointmentTypeEnum;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
@@ -217,7 +218,7 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
|
||||
$appointments = Db::table($appointmentTable)
|
||||
->whereIn('patient_id', $diagnosisIds)
|
||||
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
|
||||
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
|
||||
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status'])
|
||||
->order('appointment_date', 'asc')
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
@@ -281,21 +282,33 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
|
||||
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
|
||||
$row['visit_count'] = $completedCount;
|
||||
$row['revisit_count'] = max(0, $completedCount - 1);
|
||||
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
|
||||
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
|
||||
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
|
||||
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
|
||||
$row['appointment_doctor_name'] = $primary
|
||||
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
|
||||
: '未预约';
|
||||
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
|
||||
$row['has_appointment'] = $primary !== null ? 1 : 0;
|
||||
$this->appendPrimaryAppointmentSummary($row, $primary, $adminNames);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 所有挂号展示字段取自同一条主挂号;无挂号时不能补成视频类型。 */
|
||||
private function appendPrimaryAppointmentSummary(array &$row, ?array $primary, array $adminNames): void
|
||||
{
|
||||
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
|
||||
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
|
||||
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
|
||||
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
|
||||
$row['appointment_doctor_name'] = $primary
|
||||
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
|
||||
: '未预约';
|
||||
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
|
||||
$row['appointment_type'] = $primary !== null
|
||||
? AppointmentTypeEnum::normalizeStored($primary['appointment_type'] ?? null)
|
||||
: null;
|
||||
$row['appointment_type_desc'] = $primary !== null
|
||||
? AppointmentTypeEnum::description($row['appointment_type'])
|
||||
: '';
|
||||
$row['has_appointment'] = $primary !== null ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $appointments
|
||||
* @return array<string, mixed>|null
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\enum\AppointmentTypeEnum;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
@@ -242,7 +243,8 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
|
||||
$row['appointment_time_text'] = $this->appointmentTimeText($row);
|
||||
$row['status_text'] = $this->appointmentStatusText($status);
|
||||
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
|
||||
$row['appointment_type'] = AppointmentTypeEnum::normalizeStored($row['appointment_type'] ?? null);
|
||||
$row['appointment_type_text'] = $this->appointmentTypeText($row['appointment_type']);
|
||||
$row['registered'] = 1;
|
||||
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
|
||||
$row['visit_completed'] = $status === 3 ? 1 : 0;
|
||||
@@ -682,7 +684,7 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
|
||||
private function appointmentTypeText(string $type): string
|
||||
{
|
||||
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
|
||||
return AppointmentTypeEnum::description($type);
|
||||
}
|
||||
|
||||
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
|
||||
|
||||
@@ -34,6 +34,7 @@ use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\AppointmentCallPolicy;
|
||||
use app\common\service\FileService;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\storage\Driver as StorageDriver;
|
||||
@@ -893,6 +894,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
|
||||
$config = self::getTrtcConfig();
|
||||
if (!$config) {
|
||||
self::setError('请先配置腾讯云TRTC参数');
|
||||
@@ -928,7 +930,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
'expireTime' => 86400, // 24小时
|
||||
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
|
||||
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
|
||||
];
|
||||
] + $callPolicy;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
@@ -995,336 +997,243 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有可能以 doctor_{id} 登录 IM 的后台账号(医生 role_id=1、医助 role_id=2),用于合并会话漫游记录
|
||||
*
|
||||
* @return array<int, string> 如 ['doctor_1','doctor_2']
|
||||
*/
|
||||
private static function collectAllDoctorImPeerAccounts(): array
|
||||
/** 包含历史/停用医生;先查曾与患者相关的账号,避免先扫描大量无关会话。 */
|
||||
private static function collectDoctorImPeerAccounts(int $patientId): array
|
||||
{
|
||||
try {
|
||||
$ids = \app\common\model\auth\Admin::alias('a')
|
||||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||
->whereIn('ar.role_id', [1, 2])
|
||||
->where('a.disable', 0)
|
||||
->group('a.id')
|
||||
->column('a.id');
|
||||
$accounts = [];
|
||||
foreach ($ids as $id) {
|
||||
$accounts[] = 'doctor_' . (int)$id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($accounts));
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('collectAllDoctorImPeerAccounts: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
$accounts = ImChatMessage::where('patient_id', $patientId)->column('doctor_peer_account');
|
||||
$diagnosisIds = Diagnosis::where('patient_id', $patientId)->column('id');
|
||||
$assistantIds = Diagnosis::where('patient_id', $patientId)->column('assistant_id');
|
||||
$doctorIds = empty($diagnosisIds) ? [] : Appointment::whereIn('patient_id', $diagnosisIds)->column('doctor_id');
|
||||
// 不按当前账号启用状态排除历史会话。
|
||||
$roleIds = Db::name('admin_role')->whereIn('role_id', [1, 2])->column('admin_id');
|
||||
foreach (array_merge($assistantIds, $doctorIds, $roleIds) as $id) {
|
||||
if ((int)$id > 0) $accounts[] = 'doctor_' . (int)$id;
|
||||
}
|
||||
return array_values(array_unique(array_filter($accounts, static function ($account) {
|
||||
return is_string($account) && preg_match('/^doctor_[1-9][0-9]*$/', $account);
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 拉取与本诊单相关的 IM 单聊记录:本地归档 + 腾讯云漫游合并(归档突破云端约 7 天限制)
|
||||
*
|
||||
* @param int $diagnosisId
|
||||
* @param bool $onlyArchived 只读取本地归档,不请求腾讯云(用于首次打开快速展示)
|
||||
*/
|
||||
/** 先由控制器校验诊单权限,再以真实 patient_id 合并同患者历次诊单的记录。 */
|
||||
public static function getImChatMessagesForDiagnosis(int $diagnosisId, bool $onlyArchived = false)
|
||||
{
|
||||
try {
|
||||
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diag) {
|
||||
self::setError('诊单不存在');
|
||||
return false;
|
||||
if (!$diag || (int)$diag['patient_id'] <= 0) {
|
||||
throw new \RuntimeException('诊单不存在或缺少患者信息');
|
||||
}
|
||||
$patientId = (int)$diag['patient_id'];
|
||||
if ($patientId <= 0) {
|
||||
self::setError('诊单缺少患者信息');
|
||||
return false;
|
||||
}
|
||||
$patientImId = 'patient_' . $patientId;
|
||||
$archived = self::loadArchivedImChatRows($diagnosisId);
|
||||
|
||||
if ($onlyArchived) {
|
||||
$lists = self::attachDiagnosisIdToImMessages(
|
||||
self::enrichImMessagesWithStaffNames($archived),
|
||||
$diagnosisId
|
||||
);
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'patient_im_id' => $patientImId,
|
||||
'patient_name' => $diag['patient_name'] ?? '',
|
||||
'doctor_accounts_queried' => [],
|
||||
'only_archived' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$config = self::getTrtcConfig();
|
||||
if (!$config) {
|
||||
if (empty($archived)) {
|
||||
self::setError('请先配置腾讯云 TRTC / IM 参数');
|
||||
return false;
|
||||
}
|
||||
$lists = self::attachDiagnosisIdToImMessages(
|
||||
self::enrichImMessagesWithStaffNames($archived),
|
||||
$diagnosisId
|
||||
);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'patient_im_id' => $patientImId,
|
||||
'patient_name' => $diag['patient_name'] ?? '',
|
||||
'doctor_accounts_queried' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$doctorAccounts = self::collectAllDoctorImPeerAccounts();
|
||||
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
|
||||
if ($assistantId > 0) {
|
||||
$doctorAccounts[] = 'doctor_' . $assistantId;
|
||||
}
|
||||
$doctorAccounts = array_values(array_unique($doctorAccounts));
|
||||
if (empty($doctorAccounts)) {
|
||||
self::setError('未找到医生/医助角色账号,无法拉取 IM 记录');
|
||||
return false;
|
||||
}
|
||||
|
||||
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $doctorAccounts);
|
||||
$merged = self::mergeImMessagesByMsgId($archived, $live);
|
||||
$merged = self::attachDiagnosisIdToImMessages(
|
||||
self::enrichImMessagesWithStaffNames($merged),
|
||||
$diagnosisId
|
||||
);
|
||||
|
||||
// 首次云端拉取后异步落库,下一次即可直接读归档,无需再全量扫描医生账号
|
||||
if (!empty($live)) {
|
||||
try {
|
||||
self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('archive im chat on-the-fly failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$sync = $onlyArchived ? null : self::syncImChatArchiveForDiagnosis($diagnosisId);
|
||||
$rows = self::enrichImMessagesWithStaffNames(self::loadArchivedImChatRows((int)$diag['patient_id']));
|
||||
return [
|
||||
'lists' => $merged,
|
||||
'patient_im_id' => $patientImId,
|
||||
'lists' => self::attachDiagnosisIdToImMessages($rows, $diagnosisId),
|
||||
'patient_im_id' => 'patient_' . (int)$diag['patient_id'],
|
||||
'patient_name' => $diag['patient_name'] ?? '',
|
||||
'doctor_accounts_queried' => $doctorAccounts,
|
||||
'only_archived' => $onlyArchived,
|
||||
'sync_error' => $sync['error'] ?? '',
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务:从腾讯云拉取漫游消息写入归档表
|
||||
*
|
||||
* @return array{inserted:int, skipped_live_empty:bool, error?:string}
|
||||
* 有结果的分页同步:token 绑定当前授权诊单及登录人,浏览器无法指定任意患者/医生。
|
||||
* currentPeer 只由控制器用已登录 adminId 构建,用于聊天窗口即时同步。
|
||||
*/
|
||||
public static function syncImChatArchiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
|
||||
{
|
||||
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
|
||||
if (!self::getTrtcConfig()) throw new \RuntimeException('请先配置腾讯云 TRTC / IM 参数');
|
||||
$patientId = (int)$diag['patient_id'];
|
||||
if ($token === '') {
|
||||
$accounts = $currentPeer ? ['doctor_' . $adminId] : self::collectDoctorImPeerAccounts($patientId);
|
||||
if (!$accounts) throw new \RuntimeException('未找到可同步的医生/医助账号');
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$state = \app\common\service\ImChatSyncSession::start($accounts);
|
||||
$state['diagnosis_id'] = $diagnosisId;
|
||||
$state['patient_id'] = $patientId;
|
||||
$state['admin_id'] = $adminId;
|
||||
} else {
|
||||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) throw new \RuntimeException('同步进度无效,请重新同步');
|
||||
$state = \think\facade\Cache::get('im_chat_sync:' . $token);
|
||||
if (!is_array($state) || $state['diagnosis_id'] !== $diagnosisId || $state['patient_id'] !== $patientId || $state['admin_id'] !== $adminId) {
|
||||
throw new \RuntimeException('同步进度已失效,请重新同步');
|
||||
}
|
||||
}
|
||||
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
|
||||
\think\facade\Cache::set('im_chat_sync:' . $token, $state, 3600);
|
||||
return array_merge(['sync_token' => $token], \app\common\service\ImChatSyncSession::progress($state));
|
||||
}
|
||||
|
||||
private static function advanceImChatSync(array $state, int $diagnosisId, int $patientId): array
|
||||
{
|
||||
if (!array_key_exists('accounts_verified', $state)) {
|
||||
// 兼容发布前仍在浏览器续拉的旧 token:保留已归档数量,重新核验候选账号。
|
||||
$state = array_merge($state, \app\common\service\ImChatSyncSession::start($state['accounts']), [
|
||||
'inserted' => $state['inserted'], 'accounts_verified' => false,
|
||||
'account_check' => \app\common\service\ImChatAccountFilter::start($state['accounts'], 'patient_' . $patientId),
|
||||
]);
|
||||
unset($state['active_index'], $state['peer_started_at'], $state['peer_error_count'], $state['peer_min_time']);
|
||||
}
|
||||
if (!$state['accounts_verified']) {
|
||||
$svc = new \app\common\service\TencentImService();
|
||||
$state['account_check'] = \app\common\service\ImChatAccountFilter::step(
|
||||
$state['account_check'], static function (array $accounts) use ($svc) { return $svc->checkAccounts($accounts); }
|
||||
);
|
||||
if (\app\common\service\ImChatAccountFilter::completed($state['account_check'])) {
|
||||
$state['accounts'] = array_values(array_intersect($state['accounts'], $state['account_check']['existing']));
|
||||
$state['accounts_verified'] = true;
|
||||
}
|
||||
// 核验与历史读取分开请求,避免一轮多个腾讯请求叠加导致 HTTP 超时。
|
||||
return $state;
|
||||
}
|
||||
if (\app\common\service\ImChatSyncSession::progress($state)['completed']) return $state;
|
||||
$index = $state['index'];
|
||||
$checkpointKey = 'im_chat_complete_v1:' . $patientId . ':' . $state['accounts'][$index];
|
||||
if (($state['active_index'] ?? -1) !== $index) {
|
||||
$state['active_index'] = $index;
|
||||
$state['peer_started_at'] = time();
|
||||
$state['peer_error_count'] = count($state['errors']);
|
||||
$state['peer_min_time'] = max(0, (int)\think\facade\Cache::get($checkpointKey, 0) - 120);
|
||||
}
|
||||
if (empty($state['cursor'])) {
|
||||
// 检查点只来自完整且已落库的会话,绝不使用 MAX(msg_time)。留两分钟重叠处理边界/时钟偏差。
|
||||
$state['cursor'] = ['min_time' => $state['peer_min_time']];
|
||||
}
|
||||
$side = $state['side'];
|
||||
$svc = new \app\common\service\TencentImService();
|
||||
$next = \app\common\service\ImChatSyncSession::step(
|
||||
$state,
|
||||
static function (string $account, array $cursor) use ($svc, $patientId, $side) {
|
||||
$patient = 'patient_' . $patientId;
|
||||
// 一方删除历史不代表另一方也删除;两侧合并去重后才算同步完成。
|
||||
return \app\common\service\ImRoamMessagePager::nextPage($svc, $side === 0 ? $account : $patient, $side === 0 ? $patient : $account, $cursor);
|
||||
},
|
||||
static function (string $account, array $messages) use ($diagnosisId, $patientId) {
|
||||
$rows = [];
|
||||
foreach ($messages as $message) {
|
||||
$row = self::normalizeTimMessage($message);
|
||||
$row['doctor_peer_account'] = $account;
|
||||
$rows[] = $row;
|
||||
}
|
||||
return self::persistImChatArchiveRows($diagnosisId, $patientId, $rows);
|
||||
}
|
||||
);
|
||||
if ($next['index'] > $index && count($next['errors']) === $state['peer_error_count']) {
|
||||
\think\facade\Cache::set($checkpointKey, $state['peer_started_at'], 86400);
|
||||
}
|
||||
return $next;
|
||||
}
|
||||
|
||||
/** CLI 补历史,与页面共用分页及落库逻辑;任何会话失败都返回可见错误。 */
|
||||
public static function syncImChatArchiveForDiagnosis(int $diagnosisId): array
|
||||
{
|
||||
$out = ['inserted' => 0, 'skipped_live_empty' => false];
|
||||
try {
|
||||
if (!self::getTrtcConfig()) {
|
||||
$out['error'] = 'TRTC/IM 未配置';
|
||||
return $out;
|
||||
}
|
||||
if (!self::getTrtcConfig()) throw new \RuntimeException('TRTC/IM 未配置');
|
||||
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diag) {
|
||||
$out['error'] = '诊单不存在';
|
||||
return $out;
|
||||
}
|
||||
$accounts = self::collectAllDoctorImPeerAccounts();
|
||||
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
|
||||
if ($assistantId > 0) {
|
||||
$accounts[] = 'doctor_' . $assistantId;
|
||||
$accounts = array_values(array_unique($accounts));
|
||||
}
|
||||
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $accounts);
|
||||
if (empty($live)) {
|
||||
$out['skipped_live_empty'] = true;
|
||||
return $out;
|
||||
}
|
||||
$live = self::enrichImMessagesWithStaffNames($live);
|
||||
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
|
||||
$patientId = (int)$diag['patient_id'];
|
||||
$out['inserted'] = self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
|
||||
return $out;
|
||||
} catch (\Exception $e) {
|
||||
$state = \app\common\service\ImChatSyncSession::start(self::collectDoctorImPeerAccounts($patientId));
|
||||
if (!$state['accounts']) throw new \RuntimeException('未找到可同步的医生/医助账号');
|
||||
do {
|
||||
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
|
||||
$out['inserted'] = $state['inserted'];
|
||||
} while (!\app\common\service\ImChatSyncSession::progress($state)['completed']);
|
||||
if ($state['errors']) $out['error'] = implode(';', $state['errors']);
|
||||
$out['skipped_live_empty'] = !$state['errors'] && $out['inserted'] === 0;
|
||||
} catch (\Throwable $e) {
|
||||
$out['error'] = $e->getMessage();
|
||||
return $out;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{diagnoses:int, inserted:int, errors:array<int, string>}
|
||||
*/
|
||||
/** 定时补拉按患者去重并轮转,避免 limit 每次只扫描最新一批诊单。 */
|
||||
public static function syncImChatArchiveBatch(int $sinceDays, int $limit, ?int $onlyDiagnosisId): array
|
||||
{
|
||||
$stats = ['diagnoses' => 0, 'inserted' => 0, 'errors' => []];
|
||||
$limit = max(1, min(500, $limit));
|
||||
$q = Diagnosis::where('delete_time', null);
|
||||
$cursorKey = 'im_chat_archive_batch_cursor:' . max(0, $sinceDays);
|
||||
if ($onlyDiagnosisId !== null && $onlyDiagnosisId > 0) {
|
||||
$q->where('id', $onlyDiagnosisId);
|
||||
} elseif ($sinceDays > 0) {
|
||||
$q->where('update_time', '>=', time() - $sinceDays * 86400);
|
||||
$ids = [$onlyDiagnosisId];
|
||||
} else {
|
||||
$query = Diagnosis::where('delete_time', null)->where('patient_id', '>', 0);
|
||||
if ($sinceDays > 0) $query->where('update_time', '>=', time() - $sinceDays * 86400);
|
||||
$ids = array_map('intval', $query->group('patient_id')->column('MAX(id)'));
|
||||
sort($ids);
|
||||
$cursor = (int)\think\facade\Cache::get($cursorKey, 0);
|
||||
$pending = array_values(array_filter($ids, static function ($id) use ($cursor) { return $id > $cursor; }));
|
||||
$ids = array_slice($pending ?: $ids, 0, $limit);
|
||||
}
|
||||
$ids = $q->order('id', 'desc')->limit($limit)->column('id');
|
||||
foreach ($ids as $id) {
|
||||
$stats['diagnoses']++;
|
||||
$r = self::syncImChatArchiveForDiagnosis((int)$id);
|
||||
if (!empty($r['error'])) {
|
||||
$stats['errors'][] = 'diagnosis ' . $id . ': ' . $r['error'];
|
||||
continue;
|
||||
}
|
||||
$stats['inserted'] += (int)($r['inserted'] ?? 0);
|
||||
$result = self::syncImChatArchiveForDiagnosis((int)$id);
|
||||
$stats['inserted'] += (int)$result['inserted'];
|
||||
if (!empty($result['error'])) $stats['errors'][] = 'diagnosis ' . $id . ': ' . $result['error'];
|
||||
if (!$onlyDiagnosisId) \think\facade\Cache::set($cursorKey, (int)$id, 0);
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function loadArchivedImChatRows(int $diagnosisId): array
|
||||
private static function loadArchivedImChatRows(int $patientId): array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$list = ImChatMessage::where('diagnosis_id', $diagnosisId)
|
||||
->order('msg_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($patientId <= 0) return [];
|
||||
$list = ImChatMessage::where('patient_id', $patientId)
|
||||
->order('msg_time', 'asc')->order('id', 'asc')->select()->toArray();
|
||||
$out = [];
|
||||
foreach ($list as $row) {
|
||||
$out[] = [
|
||||
'msg_id' => (string)($row['msg_id'] ?? ''),
|
||||
'from_account' => (string)($row['from_account'] ?? ''),
|
||||
'to_account' => (string)($row['to_account'] ?? ''),
|
||||
'time' => (int)($row['msg_time'] ?? 0),
|
||||
'is_from_doctor' => !empty($row['is_from_doctor']),
|
||||
'msg_type' => (string)($row['msg_type'] ?? ''),
|
||||
'text' => (string)($row['text'] ?? ''),
|
||||
'image_url' => (string)($row['image_url'] ?? ''),
|
||||
'file_url' => (string)($row['file_url'] ?? ''),
|
||||
'file_name' => (string)($row['file_name'] ?? ''),
|
||||
'raw_elem_type' => (string)($row['raw_elem_type'] ?? ''),
|
||||
'from_staff_name' => (string)($row['from_staff_name'] ?? ''),
|
||||
'doctor_peer_account' => (string)($row['doctor_peer_account'] ?? ''),
|
||||
];
|
||||
// 同时校验账号,防止历史错误的 patient_id 把其他患者消息混进来。
|
||||
if (!self::isPatientDoctorImPair($row, $patientId)) continue;
|
||||
$row['time'] = (int)$row['msg_time'];
|
||||
$row['is_from_doctor'] = strpos((string)$row['from_account'], 'doctor_') === 0;
|
||||
if (($row['msg_type'] ?? '') === 'composite') {
|
||||
$row['parts'] = json_decode((string)$row['text'], true) ?: [];
|
||||
}
|
||||
$out[] = $row;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the parent diagnosis on every child row so clients can reject
|
||||
* accidentally mixed or stale IM payloads before rendering them.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
|
||||
private static function isPatientDoctorImPair(array $row, int $patientId): bool
|
||||
{
|
||||
foreach ($rows as &$row) {
|
||||
$row['diagnosis_id'] = $diagnosisId;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
$from = (string)($row['from_account'] ?? '');
|
||||
$to = (string)($row['to_account'] ?? '');
|
||||
$patient = 'patient_' . $patientId;
|
||||
return ($from === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $to))
|
||||
|| ($to === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $from));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $archived
|
||||
* @param array<int, array<string, mixed>> $live
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function mergeImMessagesByMsgId(array $archived, array $live): array
|
||||
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
|
||||
{
|
||||
$map = [];
|
||||
$tail = [];
|
||||
foreach ($archived as $r) {
|
||||
$k = (string)($r['msg_id'] ?? '');
|
||||
if ($k !== '') {
|
||||
$map[$k] = $r;
|
||||
} else {
|
||||
$tail[] = $r;
|
||||
}
|
||||
}
|
||||
foreach ($live as $r) {
|
||||
$k = (string)($r['msg_id'] ?? '');
|
||||
if ($k !== '') {
|
||||
$map[$k] = $r;
|
||||
} else {
|
||||
$tail[] = $r;
|
||||
}
|
||||
}
|
||||
$merged = array_values($map);
|
||||
$merged = array_merge($merged, $tail);
|
||||
usort($merged, function ($a, $b) {
|
||||
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
|
||||
});
|
||||
return $merged;
|
||||
foreach ($rows as &$row) $row['diagnosis_id'] = $diagnosisId;
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Diagnosis|array<string, mixed> $diag
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
/**
|
||||
* @param array<int, string> $doctorAccounts 已筛选的医生 IM 账号列表
|
||||
*/
|
||||
private static function pullLiveImChatMessagesForDiagnosis($diag, array $doctorAccounts = []): array
|
||||
/** 仅供已验证腾讯签名的回调使用,不接受浏览器直接上报消息正文。 */
|
||||
public static function archiveImCallbackMessage(array $payload): int
|
||||
{
|
||||
$patientId = (int)$diag['patient_id'];
|
||||
if ($patientId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$patientImId = 'patient_' . $patientId;
|
||||
if (empty($doctorAccounts)) {
|
||||
return [];
|
||||
}
|
||||
$diagnosisId = (int)($diag['id'] ?? 0);
|
||||
// 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点,
|
||||
// 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额)
|
||||
$minTimeMap = [];
|
||||
if ($diagnosisId > 0) {
|
||||
$rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->group('doctor_peer_account')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $r) {
|
||||
$acct = (string)($r['doctor_peer_account'] ?? '');
|
||||
if ($acct !== '') {
|
||||
$minTimeMap[$acct] = (int)($r['max_time'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
$imService = new \app\common\service\TencentImService();
|
||||
$merged = [];
|
||||
foreach ($doctorAccounts as $docAccount) {
|
||||
// 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重)
|
||||
$minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0;
|
||||
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime);
|
||||
foreach ($batch as $row) {
|
||||
$merged[] = $row;
|
||||
}
|
||||
}
|
||||
usort($merged, function ($a, $b) {
|
||||
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
|
||||
});
|
||||
$seen = [];
|
||||
$unique = [];
|
||||
foreach ($merged as $row) {
|
||||
$k = $row['msg_id'] ?? '';
|
||||
if ($k !== '' && isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
if ($k !== '') {
|
||||
$seen[$k] = true;
|
||||
}
|
||||
$unique[] = $row;
|
||||
}
|
||||
return $unique;
|
||||
// 腾讯也会回调发送失败的消息,这类记录不能作为成功发送的聊天历史展示。
|
||||
if (!array_key_exists('SendMsgResult', $payload) || !is_int($payload['SendMsgResult'])) {
|
||||
throw new \RuntimeException('IM 回调缺少有效发送结果');
|
||||
}
|
||||
if ($payload['SendMsgResult'] !== 0) return 0;
|
||||
$row = self::normalizeTimMessage($payload);
|
||||
$from = $row['from_account'];
|
||||
$to = $row['to_account'];
|
||||
$patientAccount = strpos($from, 'patient_') === 0 ? $from : $to;
|
||||
if (!preg_match('/^patient_([1-9][0-9]*)$/', $patientAccount, $match)) return 0;
|
||||
$patientId = (int)$match[1];
|
||||
if (!self::isPatientDoctorImPair($row, $patientId)) return 0;
|
||||
if ($row['time'] <= 0 || empty($payload['MsgBody']) || empty($payload['MsgKey'])) {
|
||||
throw new \RuntimeException('IM 回调缺少有效消息标识、时间或内容');
|
||||
}
|
||||
$diag = Diagnosis::where('patient_id', $patientId)->where('delete_time', null)->order('id', 'desc')->find();
|
||||
if (!$diag) return 0;
|
||||
$row['doctor_peer_account'] = $from === $patientAccount ? $to : $from;
|
||||
return self::persistImChatArchiveRows((int)$diag['id'], $patientId, [$row]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1334,10 +1243,30 @@ class DiagnosisLogic extends BaseLogic
|
||||
{
|
||||
$now = time();
|
||||
$chunks = [];
|
||||
// 兼容已入库的 seq_random_from 旧键;必须核对患者、双向账号及时间,不能误复用碰撞键。
|
||||
$legacyIds = array_values(array_filter(array_column($rows, 'legacy_msg_id')));
|
||||
$legacyRows = empty($legacyIds) ? [] : ImChatMessage::where('patient_id', $patientId)
|
||||
->whereIn('msg_id', $legacyIds)->select()->toArray();
|
||||
$legacyMap = array_column($legacyRows, null, 'msg_id');
|
||||
foreach ($rows as $r) {
|
||||
if (!self::isPatientDoctorImPair($r, $patientId)) {
|
||||
throw new \RuntimeException('IM 消息不属于当前患者会话');
|
||||
}
|
||||
$msgId = (string)($r['msg_id'] ?? '');
|
||||
if ($msgId === '') {
|
||||
continue;
|
||||
throw new \RuntimeException('IM 消息缺少标识');
|
||||
}
|
||||
$legacy = $legacyMap[$r['legacy_msg_id'] ?? ''] ?? null;
|
||||
if ($legacy && (int)$legacy['msg_time'] === (int)$r['time']
|
||||
&& $legacy['from_account'] === $r['from_account'] && $legacy['to_account'] === $r['to_account']) {
|
||||
$msgId = (string)$legacy['msg_id'];
|
||||
// 修复旧版本仅保存第一个消息元素的归档,仍保留原始归档诊单及去重键。
|
||||
if ($r['msg_type'] === 'composite' && (($legacy['msg_type'] ?? '') !== 'composite' || ($legacy['text'] ?? '') !== $r['text'])) {
|
||||
ImChatMessage::where('id', $legacy['id'])->where('patient_id', $patientId)->update([
|
||||
'msg_type' => 'composite', 'text' => $r['text'], 'raw_elem_type' => $r['raw_elem_type'],
|
||||
'image_url' => '', 'file_url' => '', 'file_name' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
$chunks[] = [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
@@ -1384,7 +1313,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
$flat[] = $row[$c];
|
||||
}
|
||||
}
|
||||
$sql = 'INSERT IGNORE INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh;
|
||||
// 仅重复消息键为幂等成功,表结构/长度/写库错误不能被 INSERT IGNORE 掩盖。
|
||||
$sql = 'INSERT INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh
|
||||
. ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`';
|
||||
return (int)Db::execute($sql, $flat);
|
||||
}
|
||||
|
||||
@@ -1412,91 +1343,13 @@ class DiagnosisLogic extends BaseLogic
|
||||
$f = (string)($r['from_account'] ?? '');
|
||||
if (preg_match('/^doctor_(\d+)$/', $f, $m)) {
|
||||
$aid = (int)$m[1];
|
||||
$rows[$k]['from_staff_name'] = $map[$aid] ?? '';
|
||||
$rows[$k]['from_staff_name'] = $map[$aid] ?? ($r['from_staff_name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer, int $minTime = 0): array
|
||||
{
|
||||
$out = [];
|
||||
$lastKey = null;
|
||||
$lastTime = null;
|
||||
$guard = 0;
|
||||
$maxPages = 80;
|
||||
do {
|
||||
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
|
||||
$res = null;
|
||||
$attempt = 0;
|
||||
while ($attempt < 3) {
|
||||
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
|
||||
if (!empty($res['success'])) {
|
||||
break;
|
||||
}
|
||||
$attempt++;
|
||||
\think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [
|
||||
'operator' => $operator,
|
||||
'peer' => $peer,
|
||||
'attempt' => $attempt,
|
||||
'error' => $res['error'] ?? '',
|
||||
'code' => $res['rawErrorCode'] ?? 0,
|
||||
'page' => $guard + 1,
|
||||
]);
|
||||
if ($attempt < 3) {
|
||||
usleep(300000); // 300ms 退避
|
||||
}
|
||||
}
|
||||
if (empty($res['success'])) {
|
||||
\think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [
|
||||
'operator' => $operator,
|
||||
'peer' => $peer,
|
||||
'page' => $guard + 1,
|
||||
'fetched_so_far' => count($out),
|
||||
'error' => $res['error'] ?? '',
|
||||
'code' => $res['rawErrorCode'] ?? 0,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
foreach ($res['msgList'] as $raw) {
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
$normalized = self::normalizeTimMessage($raw);
|
||||
$normalized['doctor_peer_account'] = $operator;
|
||||
$out[] = $normalized;
|
||||
}
|
||||
$complete = (int)$res['complete'];
|
||||
if ($complete === 1) {
|
||||
break;
|
||||
}
|
||||
$lastKey = $res['lastMsgKey'];
|
||||
$lastTime = $res['lastMsgTime'];
|
||||
if ($lastKey === null || $lastKey === '') {
|
||||
break;
|
||||
}
|
||||
$guard++;
|
||||
if ($guard >= $maxPages) {
|
||||
// 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多
|
||||
\think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [
|
||||
'operator' => $operator,
|
||||
'peer' => $peer,
|
||||
'min_time' => $minTime,
|
||||
'pages_fetched' => $guard,
|
||||
'fetched_so_far' => count($out),
|
||||
'last_msg_time' => $lastTime,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
* @return array<string, mixed>
|
||||
@@ -1508,13 +1361,33 @@ class DiagnosisLogic extends BaseLogic
|
||||
$time = (int)($raw['MsgTimeStamp'] ?? $raw['MsgTime'] ?? 0);
|
||||
$seq = $raw['MsgSeq'] ?? '';
|
||||
$rand = $raw['MsgRandom'] ?? '';
|
||||
$msgId = $seq . '_' . $rand . '_' . $from;
|
||||
$key = (string)($raw['MsgKey'] ?? '');
|
||||
// 发送后回调不一定带 MsgRandom,MsgKey 与云端漫游消息中的标识相同。
|
||||
if ($key !== '' && preg_match('/^(\d+)_(\d+)_\d+$/', $key, $keyParts)) {
|
||||
$seq = $seq === '' ? $keyParts[1] : $seq;
|
||||
$rand = $rand === '' ? $keyParts[2] : $rand;
|
||||
}
|
||||
$identity = $key !== '' ? $key : $seq . '_' . $rand . '_' . $time;
|
||||
if ($key === '' && ($seq === '' || $rand === '')) {
|
||||
$identity .= '_' . json_encode($raw['MsgBody'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
$msgId = 'im_' . hash('sha256', $from . "\0" . $to . "\0" . $identity);
|
||||
$isDoctor = strpos($from, 'doctor_') === 0;
|
||||
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
|
||||
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
|
||||
if (is_array($raw['MsgBody'] ?? null) && count($raw['MsgBody']) > 1) {
|
||||
$parts = [];
|
||||
foreach ($raw['MsgBody'] as $element) $parts[] = self::parseTimMsgBody([$element]);
|
||||
$parsed = [
|
||||
'msg_type' => 'composite',
|
||||
'text' => json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
'image_url' => '', 'file_url' => '', 'file_name' => '', 'raw_elem_type' => 'TIMMultiElem',
|
||||
];
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
[
|
||||
'msg_id' => $msgId,
|
||||
'legacy_msg_id' => $seq !== '' && $rand !== '' ? $seq . '_' . $rand . '_' . $from : '',
|
||||
'from_account' => $from,
|
||||
'to_account' => $to,
|
||||
'time' => $time,
|
||||
@@ -1650,6 +1523,12 @@ class DiagnosisLogic extends BaseLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
|
||||
if (!($callType === 1 ? $callPolicy['can_audio_call'] : $callPolicy['can_video_call'])) {
|
||||
self::setError($callPolicy['call_disabled_reason'] ?: '当前挂号不支持该通话方式');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建通话记录
|
||||
$record = \app\common\model\tcm\CallRecord::create([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
|
||||
@@ -141,10 +141,11 @@ class DiagnosisValidate extends BaseValidate
|
||||
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
|
||||
public function sceneCallIdentity()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'patient_id'])
|
||||
return $this->only(['diagnosis_id', 'patient_id', 'appointment_id'])
|
||||
->remove('diagnosis_id', 'checkDiagnosisId')
|
||||
->append('diagnosis_id', 'gt:0')
|
||||
->append('patient_id', 'require|integer|gt:0');
|
||||
->append('patient_id', 'require|integer|gt:0')
|
||||
->append('appointment_id', 'integer|egt:0');
|
||||
}
|
||||
|
||||
public function sceneGenerateQrcode()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\service\ImCallbackSignature;
|
||||
use JsonException;
|
||||
use think\facade\Log;
|
||||
use think\response\Json;
|
||||
use Throwable;
|
||||
|
||||
/** 腾讯 IM 单聊消息归档回调;使用腾讯签名认证,不使用患者登录会话。 */
|
||||
class ImController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['messageNotify'];
|
||||
|
||||
private const MAX_BODY_BYTES = 1048576;
|
||||
private const AFTER_SEND_COMMAND = 'C2C.CallbackAfterSendMsg';
|
||||
|
||||
public function messageNotify(): Json
|
||||
{
|
||||
if (strtoupper($this->request->method(true)) !== 'POST') {
|
||||
return $this->callbackFailure(405, 'method not allowed')->header(['Allow' => 'POST']);
|
||||
}
|
||||
$declaredLength = $this->request->header('content-length', '');
|
||||
if (is_scalar($declaredLength) && is_numeric($declaredLength) && (float) $declaredLength > self::MAX_BODY_BYTES) {
|
||||
return $this->callbackFailure(413, 'request body too large');
|
||||
}
|
||||
|
||||
$token = (string) config('im.callback_token', '');
|
||||
$sdkAppId = (string) config('project.trtc.sdkAppId', '');
|
||||
if (trim($token) === '' || preg_match('/^[1-9][0-9]*$/D', $sdkAppId) !== 1) {
|
||||
return $this->callbackFailure(503, 'callback authentication unavailable');
|
||||
}
|
||||
$query = $this->request->get();
|
||||
$actualSdkAppId = $query['SdkAppid'] ?? null;
|
||||
if ((!is_string($actualSdkAppId) && !is_int($actualSdkAppId)) || (string) $actualSdkAppId !== $sdkAppId
|
||||
|| !ImCallbackSignature::verify($token, $query['RequestTime'] ?? null, $query['Sign'] ?? null)) {
|
||||
return $this->callbackFailure(403, 'callback authentication failed');
|
||||
}
|
||||
|
||||
$raw = $this->request->getContent();
|
||||
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||
return $this->callbackFailure(413, 'request body too large');
|
||||
}
|
||||
try {
|
||||
$decoded = json_decode($raw, false, 64, JSON_THROW_ON_ERROR);
|
||||
if (!$decoded instanceof \stdClass) {
|
||||
return $this->callbackFailure(400, 'invalid callback JSON object');
|
||||
}
|
||||
$payload = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return $this->callbackFailure(400, 'invalid callback JSON object');
|
||||
}
|
||||
|
||||
$command = $payload['CallbackCommand'] ?? null;
|
||||
if (!is_string($command) || $command === '' || strlen($command) > 128
|
||||
|| (array_key_exists('CallbackCommand', $query) && $query['CallbackCommand'] !== $command)) {
|
||||
return $this->callbackFailure(400, 'callback command mismatch');
|
||||
}
|
||||
if ($command !== self::AFTER_SEND_COMMAND) {
|
||||
return $this->callbackSuccess();
|
||||
}
|
||||
|
||||
try {
|
||||
DiagnosisLogic::archiveImCallbackMessage($payload);
|
||||
} catch (Throwable) {
|
||||
// 异常信息可能包含 SQL 或患者消息,日志只保留固定事件标记。
|
||||
try {
|
||||
Log::error('IM callback archive failed');
|
||||
} catch (Throwable) {
|
||||
// 日志存储不可用也必须保留腾讯失败回包。
|
||||
}
|
||||
return $this->callbackFailure(500, 'message archive failed');
|
||||
}
|
||||
|
||||
return $this->callbackSuccess();
|
||||
}
|
||||
|
||||
private function callbackSuccess(): Json
|
||||
{
|
||||
return json(['ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => '']);
|
||||
}
|
||||
|
||||
private function callbackFailure(int $status, string $message): Json
|
||||
{
|
||||
return json(['ActionStatus' => 'FAIL', 'ErrorCode' => $status, 'ErrorInfo' => $message], $status);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class LoginMiddleware
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// 腾讯 IM 回调由控制器验证应用签名,不在验签前查询用户会话或数据库。
|
||||
if ($request->controllerObject instanceof \app\api\controller\ImController
|
||||
&& $request->action() === 'messageNotify'
|
||||
&& $request->controllerObject->isNotNeedLogin()) {
|
||||
return $next($request);
|
||||
}
|
||||
$token = $request->header('token');
|
||||
//判断接口是否免登录
|
||||
$isNotNeedLogin = $request->controllerObject->isNotNeedLogin();
|
||||
@@ -71,4 +77,4 @@ class LoginMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ class Crontab extends Command
|
||||
// 记录错误信息
|
||||
CrontabModel::where('id', $item['id'])->update([
|
||||
'error' => $e->getMessage(),
|
||||
'status' => CrontabEnum::ERROR
|
||||
// IM 补偿任务需要下轮继续重试;保留错误原因,避免一次云端抖动永久停掉归档。
|
||||
'status' => $item['command'] === 'sync_im_chat_archive' ? CrontabEnum::START : CrontabEnum::ERROR
|
||||
]);
|
||||
} finally {
|
||||
$endTime = microtime(true);
|
||||
@@ -98,4 +99,4 @@ class Crontab extends Command
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use think\console\Output;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 从腾讯云 IM 拉取诊单单聊漫游消息并写入本地归档表(建议 cron 每几小时执行)
|
||||
* 回调实时归档之外的定时补拉;按患者轮转,覆盖没有更新诊单的旧患者。
|
||||
*/
|
||||
class SyncImChatArchive extends Command
|
||||
{
|
||||
@@ -20,7 +20,7 @@ class SyncImChatArchive extends Command
|
||||
{
|
||||
$this->setName('sync_im_chat_archive')
|
||||
->setDescription('同步诊单腾讯云 IM 聊天记录到数据库归档')
|
||||
->addOption('since-days', null, Option::VALUE_OPTIONAL, '仅处理最近 N 天内更新过的诊单;0 表示不限制', '7')
|
||||
->addOption('since-days', null, Option::VALUE_OPTIONAL, '仅处理最近 N 天内更新过的诊单;默认0,覆盖旧患者聊天', '0')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本轮最多处理的诊单数量(1-500)', '50')
|
||||
->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '只同步指定诊单 ID,设置后忽略 since-days', '0');
|
||||
}
|
||||
@@ -40,12 +40,15 @@ class SyncImChatArchive extends Command
|
||||
}
|
||||
|
||||
$stats = DiagnosisLogic::syncImChatArchiveBatch($sinceDays, $limit, $only);
|
||||
$output->writeln("处理诊单数: {$stats['diagnoses']},新插入行数(INSERT IGNORE 成功数): {$stats['inserted']}");
|
||||
$output->writeln("处理患者诊单数: {$stats['diagnoses']},新归档消息数: {$stats['inserted']}");
|
||||
if (!empty($stats['errors'])) {
|
||||
foreach ($stats['errors'] as $e) {
|
||||
$output->writeln("<error>{$e}</error>");
|
||||
Log::error('sync_im_chat_archive: ' . $e);
|
||||
}
|
||||
// 项目调度器 Console::call 不转发命令退出码,抛错才能写入定时任务失败状态。
|
||||
throw new \RuntimeException('IM 聊天归档未完全同步:' . implode(';', $stats['errors']));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\AppointmentTypeEnum;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use RuntimeException;
|
||||
|
||||
/** 按本次挂号决定通话权限;Appointment.patient_id 存的是诊单 ID。 */
|
||||
class AppointmentCallPolicy
|
||||
{
|
||||
/** @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string} */
|
||||
public static function resolve(int $diagnosisId, int $appointmentId = 0): array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
throw new RuntimeException('诊单 ID 无效');
|
||||
}
|
||||
$appointments = Appointment::where('patient_id', $diagnosisId)
|
||||
->field(['id', 'appointment_type', 'status', 'appointment_date', 'appointment_time'])
|
||||
->select()->toArray();
|
||||
|
||||
return self::resolveFromAppointments($appointments, $appointmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* $appointments 必须仅包含当前诊单的挂号;未知显式 ID 不可回退到其他挂号。
|
||||
*
|
||||
* @param list<array<string,mixed>> $appointments
|
||||
* @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string}
|
||||
*/
|
||||
public static function resolveFromAppointments(array $appointments, int $appointmentId = 0): array
|
||||
{
|
||||
if ($appointmentId !== 0) {
|
||||
foreach ($appointments as $appointment) {
|
||||
if ($appointmentId > 0 && (int) ($appointment['id'] ?? 0) === $appointmentId) {
|
||||
return self::policyForAppointment($appointment);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('指定挂号不存在或不属于当前诊单');
|
||||
}
|
||||
|
||||
$active = array_values(array_filter($appointments, static fn (array $row): bool => (int) ($row['status'] ?? 0) === 1));
|
||||
if (count($active) === 1) {
|
||||
return self::policyForAppointment($active[0]);
|
||||
}
|
||||
if (count($active) > 1) {
|
||||
return array_replace(self::policyForAppointment(null), [
|
||||
'appointment_type_desc' => '待指定挂号',
|
||||
'call_disabled_reason' => '请指定本次挂号',
|
||||
]);
|
||||
}
|
||||
|
||||
$historical = array_values(array_filter($appointments, static fn (array $row): bool => in_array((int) ($row['status'] ?? 0), [3, 4], true)));
|
||||
usort($historical, static function (array $left, array $right): int {
|
||||
return [
|
||||
(string) ($right['appointment_date'] ?? ''),
|
||||
self::sortableTime($right['appointment_time'] ?? ''),
|
||||
(int) ($right['id'] ?? 0),
|
||||
] <=> [
|
||||
(string) ($left['appointment_date'] ?? ''),
|
||||
self::sortableTime($left['appointment_time'] ?? ''),
|
||||
(int) ($left['id'] ?? 0),
|
||||
];
|
||||
});
|
||||
|
||||
return self::policyForAppointment($historical[0] ?? null);
|
||||
}
|
||||
|
||||
/** @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string} */
|
||||
public static function policyForAppointment(?array $appointment): array
|
||||
{
|
||||
if ($appointment === null) {
|
||||
return [
|
||||
'appointment_id' => 0,
|
||||
'appointment_type' => null,
|
||||
'appointment_type_desc' => '未挂号',
|
||||
'can_video_call' => false,
|
||||
'can_audio_call' => false,
|
||||
'call_disabled_reason' => '未挂号,不能发起通话',
|
||||
];
|
||||
}
|
||||
|
||||
// 空类型仅对确实存在的历史挂号应用旧视频默认值。
|
||||
$type = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$callableStatus = in_array($status, [1, 3, 4], true);
|
||||
$video = $callableStatus && $type === AppointmentTypeEnum::VIDEO;
|
||||
$audio = $callableStatus && in_array($type, [AppointmentTypeEnum::VIDEO, 'phone'], true);
|
||||
$reason = match (true) {
|
||||
$status === 2 => '本次挂号已取消,不能发起通话',
|
||||
!$callableStatus => '本次挂号状态不支持通话',
|
||||
$type === AppointmentTypeEnum::TEXT => '图文问诊不支持音视频通话',
|
||||
$type === 'phone' => '电话问诊仅支持语音通话',
|
||||
!$video && !$audio => '本次挂号问诊方式不支持音视频通话',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return [
|
||||
'appointment_id' => (int) ($appointment['id'] ?? 0),
|
||||
'appointment_type' => $type,
|
||||
'appointment_type_desc' => AppointmentTypeEnum::description($type),
|
||||
'can_video_call' => $video,
|
||||
'can_audio_call' => $audio,
|
||||
'call_disabled_reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
private static function sortableTime($value): string
|
||||
{
|
||||
$time = (string) $value;
|
||||
if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches) === 1) {
|
||||
return sprintf('%02d:%02d:%02d', (int) $matches[1], (int) $matches[2], (int) ($matches[3] ?? 0));
|
||||
}
|
||||
|
||||
return $time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 腾讯 IM 回调签名:sha256(Token . RequestTime),只接受一分钟以内的请求。 */
|
||||
class ImCallbackSignature
|
||||
{
|
||||
public static function verify(string $token, mixed $requestTime, mixed $sign, ?int $now = null): bool
|
||||
{
|
||||
if (trim($token) === '' || (!is_string($requestTime) && !is_int($requestTime)) || !is_string($sign)) {
|
||||
return false;
|
||||
}
|
||||
$timestamp = (string) $requestTime;
|
||||
if (preg_match('/^[0-9]{1,12}$/D', $timestamp) !== 1 || preg_match('/^[a-fA-F0-9]{64}$/D', $sign) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (abs(($now ?? time()) - (int) $timestamp) > 60) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(hash('sha256', $token . $timestamp), strtolower($sign));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 读取历史前批量核验账号,未导入的后台成员不应作为失败会话反复拉取。 */
|
||||
final class ImChatAccountFilter
|
||||
{
|
||||
public static function start(array $accounts, string $patient): array
|
||||
{
|
||||
return [
|
||||
'patient' => $patient,
|
||||
'candidates' => array_values(array_unique(array_merge([$patient], $accounts))),
|
||||
'offset' => 0, 'existing' => [], 'missing' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/** 每步最多一次 account_check;网络/权限/结构错误由调用者明确显示,不作为缺失账号处理。 */
|
||||
public static function step(array $state, callable $check): array
|
||||
{
|
||||
$batch = array_slice($state['candidates'], $state['offset'], 100);
|
||||
if (!$batch) return $state;
|
||||
$result = $check($batch);
|
||||
if (in_array($state['patient'], $result['missing'], true)) {
|
||||
throw new \RuntimeException('当前腾讯 IM 应用中未找到患者聊天账号,请核对应用配置或患者账号;已有归档仍可查看');
|
||||
}
|
||||
$state['existing'] = array_values(array_unique(array_merge($state['existing'], $result['existing'])));
|
||||
$state['missing'] = array_values(array_unique(array_merge($state['missing'], $result['missing'])));
|
||||
$state['offset'] += count($batch);
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function completed(array $state): bool
|
||||
{
|
||||
return $state['offset'] >= count($state['candidates']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 一次 HTTP 请求只拉一页;只有归档成功后才推进游标。 */
|
||||
final class ImChatSyncSession
|
||||
{
|
||||
public static function start(array $accounts): array
|
||||
{
|
||||
return [
|
||||
'accounts' => array_values(array_unique($accounts)),
|
||||
'index' => 0, 'side' => 0, 'cursor' => [], 'inserted' => 0, 'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function step(array $state, callable $fetchPage, callable $archive): array
|
||||
{
|
||||
if ($state['index'] >= count($state['accounts'])) {
|
||||
return $state;
|
||||
}
|
||||
$account = $state['accounts'][$state['index']];
|
||||
try {
|
||||
$page = $fetchPage($account, $state['cursor']);
|
||||
} catch (\Throwable $e) {
|
||||
// 失败的会话不妨碍其他医生记录落库,下一轮仍从头补拉该会话。
|
||||
$state['errors'][] = $account . ($state['side'] === 0 ? '(医生侧)' : '(患者侧)') . ':' . $e->getMessage();
|
||||
return self::nextSide($state);
|
||||
}
|
||||
|
||||
// 落库异常交由调用者处理,绝不能把未归档的页标记为已同步。
|
||||
$state['inserted'] += $archive($account, $page['msgList']);
|
||||
if ($page['completed']) {
|
||||
$state = self::nextSide($state);
|
||||
} else {
|
||||
$state['cursor'] = $page['cursor'];
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
private static function nextSide(array $state): array
|
||||
{
|
||||
$state['cursor'] = [];
|
||||
if ($state['side'] === 0) {
|
||||
$state['side'] = 1;
|
||||
} else {
|
||||
$state['side'] = 0;
|
||||
$state['index']++;
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function progress(array $state): array
|
||||
{
|
||||
$checking = array_key_exists('accounts_verified', $state) && !$state['accounts_verified'];
|
||||
$check = $state['account_check'] ?? null;
|
||||
$completed = !$checking && $state['index'] >= count($state['accounts']);
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'phase' => $checking ? 'checking_accounts' : ($completed ? 'completed' : 'syncing'),
|
||||
'checked_accounts' => $check['offset'] ?? 0,
|
||||
'candidate_accounts' => $check ? count($check['candidates']) : 0,
|
||||
'skipped_accounts' => $check ? count($check['missing']) : 0,
|
||||
'inserted' => $state['inserted'],
|
||||
'processed_peers' => $state['index'],
|
||||
'total_peers' => count($state['accounts']),
|
||||
'errors' => $state['errors'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 单次请求一页;调用者只有在消息持久化后才保存返回的游标。 */
|
||||
class ImRoamMessagePager
|
||||
{
|
||||
private const MAX_TIME = 4294967295;
|
||||
|
||||
/**
|
||||
* @return array{msgList:array,completed:bool,cursor:array{max_time:int,last_key:?string,min_time:int,seen_keys:array}}
|
||||
* @throws \RuntimeException 云端错误码通过异常 code 保留。
|
||||
*/
|
||||
public static function nextPage(TencentImService $svc, string $operator, string $peer, array $cursor = []): array
|
||||
{
|
||||
if (trim($operator) === '' || trim($peer) === '' || $operator === $peer) {
|
||||
throw new \RuntimeException('IM会话双方账号无效');
|
||||
}
|
||||
$cursor = self::normalizeCursor($cursor);
|
||||
$response = $svc->adminGetRoamMsg(
|
||||
$operator,
|
||||
$peer,
|
||||
100,
|
||||
$cursor['min_time'],
|
||||
$cursor['max_time'],
|
||||
$cursor['last_key']
|
||||
);
|
||||
if (($response['success'] ?? null) !== true) {
|
||||
$error = is_string($response['error'] ?? null) ? trim($response['error']) : '';
|
||||
$code = is_int($response['rawErrorCode'] ?? null) ? $response['rawErrorCode'] : 0;
|
||||
throw new \RuntimeException($error !== '' ? $error : 'IM漫游消息拉取失败', $code);
|
||||
}
|
||||
if (!in_array($response['complete'] ?? null, [0, 1], true)) {
|
||||
throw new \RuntimeException('IM响应分页状态 Complete 非法');
|
||||
}
|
||||
$messages = $response['msgList'] ?? null;
|
||||
if (!is_array($messages) || array_values($messages) !== $messages) {
|
||||
throw new \RuntimeException('IM响应消息列表 MsgList 非法');
|
||||
}
|
||||
foreach ($messages as $message) {
|
||||
self::validateMessage($message, $operator, $peer, $cursor);
|
||||
}
|
||||
|
||||
$completed = $response['complete'] === 1;
|
||||
$lastTime = $response['lastMsgTime'] ?? null;
|
||||
$lastKey = $response['lastMsgKey'] ?? null;
|
||||
$hasCursor = ($lastTime !== null && $lastTime !== 0) || ($lastKey !== null && $lastKey !== '');
|
||||
if (!$completed || $hasCursor) {
|
||||
if (!is_int($lastTime) || $lastTime <= 0 || !is_string($lastKey) || trim($lastKey) === '') {
|
||||
throw new \RuntimeException('IM响应缺少有效续页游标 LastMsgTime/LastMsgKey');
|
||||
}
|
||||
if ($lastTime < $cursor['min_time'] || $lastTime > $cursor['max_time']) {
|
||||
throw new \RuntimeException('IM响应续页时间超出请求范围');
|
||||
}
|
||||
$seenKeys = $lastTime === $cursor['max_time'] ? $cursor['seen_keys'] : [];
|
||||
if (in_array($lastKey, $seenKeys, true)) {
|
||||
throw new \RuntimeException('IM响应续页游标重复,分页未向前推进');
|
||||
}
|
||||
$seenKeys[] = $lastKey;
|
||||
$cursor['max_time'] = $lastTime;
|
||||
$cursor['last_key'] = $lastKey;
|
||||
$cursor['seen_keys'] = $seenKeys;
|
||||
}
|
||||
|
||||
return ['msgList' => $messages, 'completed' => $completed, 'cursor' => $cursor];
|
||||
}
|
||||
|
||||
/** 不以本地归档最新时间作下界,初次扫描覆盖云端仍保留的全部记录。 */
|
||||
private static function normalizeCursor(array $cursor): array
|
||||
{
|
||||
$maxTime = $cursor['max_time'] ?? self::MAX_TIME;
|
||||
$minTime = $cursor['min_time'] ?? 0;
|
||||
$lastKey = $cursor['last_key'] ?? null;
|
||||
$seenKeys = $cursor['seen_keys'] ?? [];
|
||||
if (!is_int($minTime) || !is_int($maxTime) || $minTime < 0 || $maxTime > self::MAX_TIME || $minTime > $maxTime) {
|
||||
throw new \RuntimeException('IM请求时间游标无效');
|
||||
}
|
||||
if ($lastKey === '') {
|
||||
$lastKey = null;
|
||||
}
|
||||
if ($lastKey !== null && (!is_string($lastKey) || trim($lastKey) === '')) {
|
||||
throw new \RuntimeException('IM请求消息游标无效');
|
||||
}
|
||||
if (!is_array($seenKeys) || array_values($seenKeys) !== $seenKeys) {
|
||||
throw new \RuntimeException('IM请求历史游标无效');
|
||||
}
|
||||
foreach ($seenKeys as $key) {
|
||||
if (!is_string($key) || trim($key) === '') {
|
||||
throw new \RuntimeException('IM请求历史游标无效');
|
||||
}
|
||||
}
|
||||
if ($lastKey !== null && !in_array($lastKey, $seenKeys, true)) {
|
||||
$seenKeys[] = $lastKey;
|
||||
}
|
||||
|
||||
return ['max_time' => $maxTime, 'last_key' => $lastKey, 'min_time' => $minTime, 'seen_keys' => $seenKeys];
|
||||
}
|
||||
|
||||
private static function validateMessage($message, string $operator, string $peer, array $cursor): void
|
||||
{
|
||||
if (!is_array($message)) {
|
||||
throw new \RuntimeException('IM响应包含非法消息');
|
||||
}
|
||||
$from = $message['From_Account'] ?? null;
|
||||
$to = $message['To_Account'] ?? null;
|
||||
if (!(($from === $operator && $to === $peer) || ($from === $peer && $to === $operator))) {
|
||||
throw new \RuntimeException('IM响应消息不属于当前会话,已停止同步');
|
||||
}
|
||||
$key = $message['MsgKey'] ?? null;
|
||||
$time = $message['MsgTimeStamp'] ?? null;
|
||||
if (!is_string($key) || trim($key) === '' || !is_int($time)
|
||||
|| $time < $cursor['min_time'] || $time > $cursor['max_time']
|
||||
|| !is_array($message['MsgBody'] ?? null)) {
|
||||
throw new \RuntimeException('IM响应消息标识、时间或内容格式非法');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,100 @@ class TencentImService
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读检查一批账号是否已导入 IM;不自动导入,也不在一次调用里拆成多个请求。
|
||||
* @see https://cloud.tencent.com/document/product/269/38417
|
||||
* @return array{existing:array,missing:array}
|
||||
* @throws \RuntimeException 单批最多 100 个不同账号;任何检查失败都保留错误码。
|
||||
*/
|
||||
public function checkAccounts(array $accounts): array
|
||||
{
|
||||
foreach ($accounts as $account) {
|
||||
if (!is_string($account) || trim($account) === '') {
|
||||
throw new \RuntimeException('IM账号查询参数必须是非空账号字符串');
|
||||
}
|
||||
}
|
||||
$accounts = array_values(array_unique($accounts, SORT_STRING));
|
||||
if ($accounts === []) {
|
||||
return ['existing' => [], 'missing' => []];
|
||||
}
|
||||
if (count($accounts) > 100) {
|
||||
throw new \RuntimeException('IM账号查询单批最多支持100个账号');
|
||||
}
|
||||
|
||||
try {
|
||||
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
|
||||
if (!$adminUserSig) {
|
||||
throw new \RuntimeException('生成管理员UserSig失败');
|
||||
}
|
||||
$url = sprintf(
|
||||
'https://console.tim.qq.com/v4/im_open_login_svc/account_check?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
|
||||
$this->sdkAppId,
|
||||
$this->adminIdentifier,
|
||||
urlencode($adminUserSig),
|
||||
rand(0, 4294967295)
|
||||
);
|
||||
$data = ['CheckItem' => array_map(static function (string $account): array {
|
||||
return ['UserID' => $account];
|
||||
}, $accounts)];
|
||||
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
|
||||
if (!is_string($result) || $result === '') {
|
||||
throw new \RuntimeException('IM账号查询接口无响应');
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
if (!is_array($response) || !is_int($response['ErrorCode'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询响应格式非法或缺少ErrorCode');
|
||||
}
|
||||
$code = $response['ErrorCode'];
|
||||
if (($response['ActionStatus'] ?? null) !== 'OK' || $code !== 0) {
|
||||
$info = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
|
||||
throw new \RuntimeException($info !== '' ? $info : 'IM账号查询失败:ErrorCode ' . $code, $code);
|
||||
}
|
||||
$items = $response['ResultItem'] ?? null;
|
||||
if (!is_array($items) || array_values($items) !== $items) {
|
||||
throw new \RuntimeException('IM账号查询响应缺少有效ResultItem列表');
|
||||
}
|
||||
$requested = array_fill_keys($accounts, true);
|
||||
$statuses = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item) || !is_string($item['UserID'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效UserID');
|
||||
}
|
||||
$account = $item['UserID'];
|
||||
if (!isset($requested[$account]) || isset($statuses[$account])) {
|
||||
throw new \RuntimeException('IM账号查询结果包含未请求或重复的账号');
|
||||
}
|
||||
if (!is_int($item['ResultCode'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效ResultCode');
|
||||
}
|
||||
if ($item['ResultCode'] !== 0) {
|
||||
$info = is_string($item['ResultInfo'] ?? null) ? trim($item['ResultInfo']) : '';
|
||||
throw new \RuntimeException(
|
||||
$info !== '' ? $info : 'IM单个账号查询失败:ResultCode ' . $item['ResultCode'],
|
||||
$item['ResultCode']
|
||||
);
|
||||
}
|
||||
if (!in_array($item['AccountStatus'] ?? null, ['Imported', 'NotImported'], true)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效AccountStatus');
|
||||
}
|
||||
$statuses[$account] = $item['AccountStatus'];
|
||||
}
|
||||
if (count($statuses) !== count($accounts)) {
|
||||
throw new \RuntimeException('IM账号查询结果不完整,部分账号未返回检查结果');
|
||||
}
|
||||
|
||||
$out = ['existing' => [], 'missing' => []];
|
||||
foreach ($accounts as $account) {
|
||||
$out[$statuses[$account] === 'Imported' ? 'existing' : 'missing'][] = $account;
|
||||
}
|
||||
return $out;
|
||||
} catch (\RuntimeException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
throw new \RuntimeException($exception->getMessage(), (int)$exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除账号
|
||||
@@ -235,7 +329,7 @@ class TencentImService
|
||||
|
||||
/**
|
||||
* 拉取单聊(C2C)漫游消息
|
||||
* @see https://cloud.tencent.com/document/product/269/2739
|
||||
* @see https://cloud.tencent.cn/document/product/269/42794
|
||||
*
|
||||
* @param string $operatorAccount 会话一方 UserID(如 doctor_1)
|
||||
* @param string $peerAccount 会话另一方 UserID(如 patient_2)
|
||||
@@ -253,7 +347,7 @@ class TencentImService
|
||||
$empty = [
|
||||
'success' => false,
|
||||
'msgList' => [],
|
||||
'complete' => 1,
|
||||
'complete' => 0,
|
||||
'lastMsgKey' => null,
|
||||
'lastMsgTime' => null,
|
||||
'error' => '',
|
||||
@@ -278,45 +372,61 @@ class TencentImService
|
||||
'Peer_Account' => $peerAccount,
|
||||
'MaxCnt' => $maxCnt,
|
||||
'MinTime' => $minTime,
|
||||
'MaxTime' => $maxTime,
|
||||
// 保留旧方法参数;续页时间必须写入 MaxTime,LastMsgTime 仅为响应字段。
|
||||
'MaxTime' => $lastMsgTime ?? $maxTime,
|
||||
];
|
||||
if ($lastMsgKey !== null && $lastMsgKey !== '') {
|
||||
$data['LastMsgKey'] = $lastMsgKey;
|
||||
}
|
||||
if ($lastMsgTime !== null && $lastMsgTime > 0) {
|
||||
$data['LastMsgTime'] = $lastMsgTime;
|
||||
}
|
||||
// 增加超时时间到 60 秒
|
||||
$result = $this->httpPost($url, json_encode($data), 60);
|
||||
// 每个同步步骤只请求一页,超时后交由下一步骤重试。
|
||||
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
|
||||
if (!$result) {
|
||||
$empty['error'] = 'IM接口无响应';
|
||||
return $empty;
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
if (!$response) {
|
||||
if (!is_array($response)) {
|
||||
$empty['error'] = 'IM响应解析失败';
|
||||
return $empty;
|
||||
}
|
||||
$code = (int)($response['ErrorCode'] ?? -1);
|
||||
$code = is_int($response['ErrorCode'] ?? null) ? $response['ErrorCode'] : -1;
|
||||
$empty['rawErrorCode'] = $code;
|
||||
if (($response['ActionStatus'] ?? '') !== 'OK') {
|
||||
$empty['error'] = $response['ErrorInfo'] ?? ('ErrorCode ' . $code);
|
||||
if (($response['ActionStatus'] ?? '') !== 'OK' || $code !== 0) {
|
||||
$errorInfo = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
|
||||
$empty['error'] = $errorInfo !== '' ? $errorInfo : ('IM接口返回错误:ErrorCode ' . $code);
|
||||
return $empty;
|
||||
}
|
||||
$msgList = $response['MsgList'] ?? [];
|
||||
if (!is_array($msgList)) {
|
||||
$msgList = [];
|
||||
$msgList = $response['MsgList'] ?? null;
|
||||
if (!is_array($msgList) || array_values($msgList) !== $msgList) {
|
||||
$empty['error'] = 'IM响应消息列表 MsgList 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (!in_array($response['Complete'] ?? null, [0, 1], true)) {
|
||||
$empty['error'] = 'IM响应分页状态 Complete 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['MsgCnt']) && (!is_int($response['MsgCnt']) || $response['MsgCnt'] !== count($msgList))) {
|
||||
$empty['error'] = 'IM响应消息条数 MsgCnt 与 MsgList 不一致';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['LastMsgTime']) && !is_int($response['LastMsgTime'])) {
|
||||
$empty['error'] = 'IM响应游标 LastMsgTime 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['LastMsgKey']) && !is_string($response['LastMsgKey'])) {
|
||||
$empty['error'] = 'IM响应游标 LastMsgKey 非法';
|
||||
return $empty;
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'msgList' => $msgList,
|
||||
'complete' => (int)($response['Complete'] ?? 1),
|
||||
'complete' => $response['Complete'],
|
||||
'lastMsgKey' => $response['LastMsgKey'] ?? null,
|
||||
'lastMsgTime' => isset($response['LastMsgTime']) ? (int)$response['LastMsgTime'] : null,
|
||||
'lastMsgTime' => $response['LastMsgTime'] ?? null,
|
||||
'error' => '',
|
||||
'rawErrorCode' => $code,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
$empty['error'] = $e->getMessage();
|
||||
return $empty;
|
||||
}
|
||||
@@ -328,7 +438,7 @@ class TencentImService
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
private function httpPost(string $url, string $data, int $timeout = 10)
|
||||
protected function httpPost(string $url, string $data, int $timeout = 10)
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// 与腾讯 IM 控制台「回调配置 → 鉴权 Token」一致;空值拒绝全部回调。
|
||||
'callback_token' => env('im.callback_token', ''),
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 现有归档表不需要迁移。把原任务的7天诊单更新时间过滤改为覆盖所有患者,程序会自动轮转。
|
||||
-- 按实际部署表前缀替换 zyt_。仅改现有此命令的任务,不创建重复定时任务。
|
||||
UPDATE `zyt_dev_crontab`
|
||||
SET `params` = '--since-days=0 --limit=200',
|
||||
`remark` = '按患者轮转补拉IM聊天记录;实时消息由腾讯IM发送后回调归档',
|
||||
`update_time` = UNIX_TIMESTAMP()
|
||||
WHERE `command` = 'sync_im_chat_archive'
|
||||
AND `params` IN ('--since-days=7 --limit=200', '--since-days=7 --limit=50', '');
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 只加载纯策略与枚举,模型使用内存替身;不启动框架或读取数据库配置。
|
||||
require dirname(__DIR__) . '/app/common/enum/AppointmentTypeEnum.php';
|
||||
require dirname(__DIR__) . '/app/common/service/AppointmentCallPolicy.php';
|
||||
|
||||
use app\common\service\AppointmentCallPolicy;
|
||||
|
||||
final class AppointmentCallPolicyFixtureModel
|
||||
{
|
||||
public static array $rows = [];
|
||||
public static array $lastWhere = [];
|
||||
|
||||
public static function where(string $field, int $value): AppointmentCallPolicyFixtureQuery
|
||||
{
|
||||
self::$lastWhere = [$field, $value];
|
||||
return new AppointmentCallPolicyFixtureQuery(array_values(array_filter(
|
||||
self::$rows,
|
||||
static fn (array $row): bool => (int) ($row[$field] ?? 0) === $value
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
final class AppointmentCallPolicyFixtureQuery
|
||||
{
|
||||
public function __construct(private array $rows) {}
|
||||
public function field(array $fields): self { return $this; }
|
||||
public function select(): self { return $this; }
|
||||
public function toArray(): array { return $this->rows; }
|
||||
}
|
||||
|
||||
class_alias(AppointmentCallPolicyFixtureModel::class, 'app\\common\\model\\doctor\\Appointment');
|
||||
|
||||
$assertions = 0;
|
||||
function callPolicyExpect(bool $condition, string $message): void
|
||||
{
|
||||
global $assertions;
|
||||
$assertions++;
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function callPolicyRow(int $id, ?string $type, int $status = 1, string $date = '2026-09-09', string $time = '09:00:00'): array
|
||||
{
|
||||
return ['id' => $id, 'patient_id' => 101, 'appointment_type' => $type, 'status' => $status, 'appointment_date' => $date, 'appointment_time' => $time];
|
||||
}
|
||||
|
||||
function callPolicyRejects(callable $callback, string $messagePart): void
|
||||
{
|
||||
try {
|
||||
$callback();
|
||||
} catch (RuntimeException $error) {
|
||||
callPolicyExpect(str_contains($error->getMessage(), $messagePart), 'rejection explains ' . $messagePart);
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected rejection: ' . $messagePart);
|
||||
}
|
||||
|
||||
$video = AppointmentCallPolicy::policyForAppointment(callPolicyRow(1, 'video'));
|
||||
callPolicyExpect($video['appointment_type'] === 'video' && $video['appointment_type_desc'] === '视频问诊', 'video retains accurate type and label');
|
||||
callPolicyExpect($video['can_video_call'] && $video['can_audio_call'] && $video['call_disabled_reason'] === '', 'video enables both media');
|
||||
$text = AppointmentCallPolicy::policyForAppointment(callPolicyRow(2, 'text'));
|
||||
callPolicyExpect(!$text['can_video_call'] && !$text['can_audio_call'] && $text['appointment_type_desc'] === '图文问诊', 'text enables neither video nor audio');
|
||||
$phone = AppointmentCallPolicy::policyForAppointment(callPolicyRow(3, 'phone'));
|
||||
callPolicyExpect(!$phone['can_video_call'] && $phone['can_audio_call'] && $phone['appointment_type_desc'] === '电话问诊', 'legacy phone allows audio only');
|
||||
|
||||
foreach (['offline', 'unknown', 'Video', ' video '] as $type) {
|
||||
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(4, $type));
|
||||
callPolicyExpect($policy['appointment_type'] === $type && !$policy['can_video_call'] && !$policy['can_audio_call'], 'unsupported stored type never becomes video: ' . $type);
|
||||
}
|
||||
foreach ([null, '', ' '] as $type) {
|
||||
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(5, $type));
|
||||
callPolicyExpect($policy['appointment_type'] === 'video' && $policy['can_video_call'], 'a real historical blank registration follows existing video normalization');
|
||||
}
|
||||
foreach (['video', 'text', 'phone', null] as $type) {
|
||||
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(6, $type, 2));
|
||||
callPolicyExpect(!$policy['can_video_call'] && !$policy['can_audio_call'] && str_contains($policy['call_disabled_reason'], '已取消'), 'canceled registration never enables calls');
|
||||
}
|
||||
$invalidStatus = AppointmentCallPolicy::policyForAppointment(callPolicyRow(7, 'video', 0));
|
||||
callPolicyExpect(!$invalidStatus['can_video_call'] && !$invalidStatus['can_audio_call'], 'unrecognized status cannot grant calls');
|
||||
|
||||
$none = AppointmentCallPolicy::resolveFromAppointments([]);
|
||||
callPolicyExpect($none === [
|
||||
'appointment_id' => 0,
|
||||
'appointment_type' => null,
|
||||
'appointment_type_desc' => '未挂号',
|
||||
'can_video_call' => false,
|
||||
'can_audio_call' => false,
|
||||
'call_disabled_reason' => '未挂号,不能发起通话',
|
||||
], 'missing registration never fabricates video eligibility');
|
||||
|
||||
$mixed = [callPolicyRow(10, 'text'), callPolicyRow(11, 'video')];
|
||||
$ambiguous = AppointmentCallPolicy::resolveFromAppointments($mixed);
|
||||
callPolicyExpect($ambiguous['appointment_id'] === 0 && $ambiguous['appointment_type'] === null, 'multiple active registrations do not guess identity or mode');
|
||||
callPolicyExpect(!$ambiguous['can_video_call'] && !$ambiguous['can_audio_call'] && $ambiguous['call_disabled_reason'] === '请指定本次挂号', 'multiple active registrations require an explicit choice');
|
||||
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments($mixed, 10)['appointment_type'] === 'text', 'explicit text choice is not replaced by video');
|
||||
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments($mixed, 11)['can_video_call'], 'explicit video choice is permitted');
|
||||
$sameMode = AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(10, 'video'), callPolicyRow(11, 'video')]);
|
||||
callPolicyExpect(!$sameMode['can_video_call'], 'multiple registrations still require a choice when their modes match');
|
||||
callPolicyRejects(static fn () => AppointmentCallPolicy::resolveFromAppointments($mixed, 99), '不属于当前诊单');
|
||||
callPolicyRejects(static fn () => AppointmentCallPolicy::resolveFromAppointments($mixed, -1), '不属于当前诊单');
|
||||
|
||||
$singleActive = AppointmentCallPolicy::resolveFromAppointments([
|
||||
callPolicyRow(20, 'text', 1, '2026-08-01'),
|
||||
callPolicyRow(21, 'video', 3, '2026-09-09'),
|
||||
callPolicyRow(22, 'video', 2, '2026-09-10'),
|
||||
]);
|
||||
callPolicyExpect($singleActive['appointment_id'] === 20 && !$singleActive['can_video_call'], 'single active registration wins over newer history and cancellation');
|
||||
|
||||
$history = [
|
||||
callPolicyRow(101, 'video', 3, '2026-09-08', '23:00'),
|
||||
callPolicyRow(30, 'phone', 4, '2026-09-09', '09:00'),
|
||||
callPolicyRow(32, 'text', 3, '2026-09-09', '9:30'),
|
||||
callPolicyRow(31, 'video', 3, '2026-09-09', '09:30:00'),
|
||||
callPolicyRow(33, 'video', 2, '2026-09-10', '10:00:00'),
|
||||
];
|
||||
$latest = AppointmentCallPolicy::resolveFromAppointments($history);
|
||||
callPolicyExpect($latest['appointment_id'] === 32 && $latest['appointment_type'] === 'text' && !$latest['can_video_call'], 'history uses date then normalized time then id, ignoring canceled entries');
|
||||
$explicitCanceled = AppointmentCallPolicy::resolveFromAppointments($history, 33);
|
||||
callPolicyExpect($explicitCanceled['appointment_id'] === 33 && $explicitCanceled['appointment_type'] === 'video' && !$explicitCanceled['can_video_call'], 'explicit canceled choice preserves identity and accurate type but denies calls');
|
||||
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(40, 'video', 2)])['appointment_type'] === null, 'canceled-only history does not invent an eligible registration');
|
||||
foreach ([3, 4] as $status) {
|
||||
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(41, 'video', $status)])['can_video_call'], 'completed/missed history remains usable for legacy chat');
|
||||
}
|
||||
|
||||
AppointmentCallPolicyFixtureModel::$rows = [
|
||||
callPolicyRow(50, 'text'),
|
||||
array_replace(callPolicyRow(99, 'video'), ['patient_id' => 202]),
|
||||
];
|
||||
$resolved = AppointmentCallPolicy::resolve(101, 50);
|
||||
callPolicyExpect(AppointmentCallPolicyFixtureModel::$lastWhere === ['patient_id', 101], 'model query scopes appointment.patient_id to diagnosis id');
|
||||
callPolicyExpect($resolved['appointment_id'] === 50 && !$resolved['can_video_call'], 'scoped resolve uses the requested registration');
|
||||
callPolicyRejects(static fn () => AppointmentCallPolicy::resolve(101, 99), '不属于当前诊单');
|
||||
callPolicyRejects(static fn () => AppointmentCallPolicy::resolve(0), '诊单 ID 无效');
|
||||
|
||||
echo "Appointment call policy: {$assertions} assertions passed (in-memory model, no database)\n";
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Generate SQL through the real list class and ThinkPHP builder without connecting to a database.
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
final class AppointmentFilterNoConnection extends think\db\connector\Mysql
|
||||
{
|
||||
public function connect(array $config = [], $linkNum = 0, $autoConnection = false): PDO
|
||||
{
|
||||
throw new RuntimeException('Appointment filter tests must never open a database connection');
|
||||
}
|
||||
|
||||
public function getFields(string $tableName): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
final class AppointmentFilterQuery extends think\db\Query
|
||||
{
|
||||
public static array $captured = [];
|
||||
|
||||
public function select(array $data = []): think\Collection
|
||||
{
|
||||
self::$captured[isset($this->getOptions()['group']) ? 'tabs' : 'lists'] = $this->buildSql(false);
|
||||
return new think\Collection([]);
|
||||
}
|
||||
|
||||
public function count(string $field = '*'): int
|
||||
{
|
||||
self::$captured['count'] = $this->fetchSql()->count($field);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
final class AppointmentFilterModel
|
||||
{
|
||||
public static function alias(string $alias): AppointmentFilterQuery
|
||||
{
|
||||
return (new AppointmentFilterQuery(new AppointmentFilterNoConnection(['type' => 'mysql'])))
|
||||
->table('doctor_appointment')->alias($alias);
|
||||
}
|
||||
}
|
||||
|
||||
final class AppointmentFilterLookup
|
||||
{
|
||||
public static function where(...$args): self { return new self(); }
|
||||
public function column(...$args): array { return []; }
|
||||
}
|
||||
|
||||
class_alias(AppointmentFilterModel::class, 'app\\common\\model\\doctor\\Appointment');
|
||||
class_alias(AppointmentFilterLookup::class, 'app\\common\\model\\auth\\AdminRole');
|
||||
class_alias(AppointmentFilterLookup::class, 'app\\common\\model\\dict\\DictData');
|
||||
|
||||
$cases = [
|
||||
'omitted' => [],
|
||||
'all' => ['appointment_type' => ''],
|
||||
'null' => ['appointment_type' => null],
|
||||
'video' => ['appointment_type' => 'video'],
|
||||
'text' => ['appointment_type' => 'text'],
|
||||
'video_status_1' => ['appointment_type' => 'video', 'status' => 1],
|
||||
'text_doctor' => ['appointment_type' => 'text', 'doctor_id' => 201],
|
||||
'video_patient' => ['appointment_type' => 'video', 'patient_id' => 101],
|
||||
'video_page' => ['appointment_type' => 'video', 'fixture_offset' => 1, 'fixture_length' => 2],
|
||||
'invalid_unknown' => ['appointment_type' => 'unknown'],
|
||||
'invalid_legacy_phone' => ['appointment_type' => 'phone'],
|
||||
'invalid_case' => ['appointment_type' => 'Video'],
|
||||
'invalid_padded' => ['appointment_type' => ' video '],
|
||||
'invalid_blank' => ['appointment_type' => ' '],
|
||||
'invalid_number' => ['appointment_type' => 0],
|
||||
'invalid_boolean' => ['appointment_type' => false],
|
||||
'invalid_array' => ['appointment_type' => ['video']],
|
||||
'invalid_injection' => ['appointment_type' => "video' OR 1=1 --"],
|
||||
];
|
||||
$reflection = new ReflectionClass(app\adminapi\lists\doctor\AppointmentLists::class);
|
||||
$result = [];
|
||||
foreach ($cases as $name => $params) {
|
||||
$lists = $reflection->newInstanceWithoutConstructor();
|
||||
$reflection->getProperty('params')->setValue($lists, $params + ['progress_board' => 1, 'include_status_counts' => 1]);
|
||||
$reflection->getProperty('adminId')->setValue($lists, 7);
|
||||
$reflection->getProperty('searchWhere')->setValue($lists, []);
|
||||
$lists->limitOffset = $params['fixture_offset'] ?? 0;
|
||||
$lists->limitLength = $params['fixture_length'] ?? 100;
|
||||
AppointmentFilterQuery::$captured = [];
|
||||
// Count and tabs must work before lists() populates searchWhere.
|
||||
$lists->count();
|
||||
$lists->extend();
|
||||
$lists->lists();
|
||||
$result[$name] = AppointmentFilterQuery::$captured;
|
||||
}
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\lists\firstvisit\MyPatientLists;
|
||||
use app\adminapi\lists\firstvisit\MyPatientProgressLists;
|
||||
use app\adminapi\lists\tcm\DiagnosisLists;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
use app\common\enum\AppointmentTypeEnum;
|
||||
@@ -41,7 +43,7 @@ foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $labe
|
||||
appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice');
|
||||
}
|
||||
|
||||
$invalidValues = ['', ' ', 'phone', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
|
||||
$invalidValues = ['', ' ', 'phone', 'offline', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
|
||||
foreach ($invalidValues as $value) {
|
||||
foreach (['create', 'adminEdit'] as $scene) {
|
||||
appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value));
|
||||
@@ -80,4 +82,54 @@ appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appoin
|
||||
$summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]);
|
||||
appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type');
|
||||
|
||||
echo "Appointment type validation, defaults, legacy labels and summary: OK\n";
|
||||
$myPatientReflection = new ReflectionClass(MyPatientLists::class);
|
||||
$myPatientLists = $myPatientReflection->newInstanceWithoutConstructor();
|
||||
$pickPrimary = $myPatientReflection->getMethod('pickPrimaryAppointment');
|
||||
$primarySummary = $myPatientReflection->getMethod('appendPrimaryAppointmentSummary');
|
||||
$appointments = [
|
||||
['id' => 101, 'doctor_id' => 201, 'appointment_date' => '2026-09-09', 'appointment_time' => '08:30:00', 'status' => 3, 'appointment_type' => 'video'],
|
||||
['id' => 102, 'doctor_id' => 202, 'appointment_date' => '2026-09-10', 'appointment_time' => '09:30:00', 'status' => 1, 'appointment_type' => 'text'],
|
||||
['id' => 103, 'doctor_id' => 203, 'appointment_date' => '2026-09-11', 'appointment_time' => '10:30:00', 'status' => 1, 'appointment_type' => 'video'],
|
||||
];
|
||||
$adminNames = [201 => '医生甲', 202 => '医生乙', 203 => '医生丙'];
|
||||
$row = [];
|
||||
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10');
|
||||
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
|
||||
appointmentTypeExpect(
|
||||
$row['appointment_id'] === 102 && $row['appointment_doctor_id'] === 202
|
||||
&& $row['appointment_time_text'] === '2026-09-10 09:30'
|
||||
&& $row['appointment_type'] === 'text' && $row['appointment_type_desc'] === '图文问诊',
|
||||
'my patient row uses the upcoming primary appointment type instead of the latest appointment type'
|
||||
);
|
||||
|
||||
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '2026-09-11', '2026-09-11', '2026-09-10');
|
||||
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
|
||||
appointmentTypeExpect($row['appointment_id'] === 103 && $row['appointment_type'] === 'video', 'date filter updates both the primary appointment and its type');
|
||||
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10', [3]);
|
||||
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
|
||||
appointmentTypeExpect($row['appointment_id'] === 101 && $row['appointment_type'] === 'video', 'status filter updates both the primary appointment and its type');
|
||||
|
||||
foreach ([null, '', ' ', 'phone', 'unknown'] as $storedType) {
|
||||
$primary = $appointments[1];
|
||||
$primary['appointment_type'] = $storedType;
|
||||
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
|
||||
appointmentTypeExpect($row['appointment_type'] === AppointmentTypeEnum::normalizeStored($storedType), 'my patient row normalizes only legacy empty types');
|
||||
appointmentTypeExpect($row['appointment_type_desc'] === AppointmentTypeEnum::description($storedType), 'my patient row preserves historical and unknown labels');
|
||||
}
|
||||
|
||||
$primary = $pickPrimary->invoke($myPatientLists, [], '', '', '2026-09-10');
|
||||
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
|
||||
appointmentTypeExpect(
|
||||
$row['has_appointment'] === 0 && $row['appointment_id'] === 0
|
||||
&& $row['appointment_type'] === null && $row['appointment_type_desc'] === '',
|
||||
'a patient without an appointment does not inherit a previous type or gain a default video appointment'
|
||||
);
|
||||
|
||||
$progressReflection = new ReflectionClass(MyPatientProgressLists::class);
|
||||
$progressLists = $progressReflection->newInstanceWithoutConstructor();
|
||||
$progressTypeText = $progressReflection->getMethod('appointmentTypeText');
|
||||
foreach (['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊', '' => '视频问诊', ' ' => '视频问诊', 'unknown' => '未知'] as $type => $label) {
|
||||
appointmentTypeExpect($progressTypeText->invoke($progressLists, $type) === $label, 'progress labels agree with stored appointment types');
|
||||
}
|
||||
|
||||
echo "Appointment type validation, defaults, legacy labels and primary summaries: OK\n";
|
||||
|
||||
@@ -66,7 +66,7 @@ callSignatureExpect(
|
||||
'controller validates both ids and forwards the authenticated row-scope context'
|
||||
);
|
||||
callSignatureExpect(
|
||||
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id'])")
|
||||
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id', 'appointment_id'])")
|
||||
&& str_contains($validatorSource, "append('patient_id', 'require|integer|gt:0')"),
|
||||
'call identity validation requires positive diagnosis and patient ids'
|
||||
);
|
||||
|
||||
@@ -216,11 +216,11 @@ diagnosisWorkspaceAuthExpect(
|
||||
diagnosisWorkspaceAuthExpect(
|
||||
str_contains($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
|
||||
&& strpos($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
|
||||
< strpos($imChatSyncControllerMethod, 'register_shutdown_function('),
|
||||
'IM archive sync authorizes the diagnosis before queuing background work'
|
||||
< strpos($imChatSyncControllerMethod, 'DiagnosisLogic::syncImChatArchiveStep('),
|
||||
'IM archive sync authorizes the diagnosis before syncing any cloud page'
|
||||
);
|
||||
diagnosisWorkspaceAuthExpect(
|
||||
substr_count($imChatLogicMethod, 'attachDiagnosisIdToImMessages(') >= 3
|
||||
str_contains($imChatLogicMethod, 'attachDiagnosisIdToImMessages(')
|
||||
&& str_contains($imChatOwnerMethod, "\$row['diagnosis_id'] = \$diagnosisId"),
|
||||
'every IM response row declares its parent diagnosis for client-side ownership checks'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller {
|
||||
// 覆盖配置读取以防测试读取真实 .env;控制器本身和 ThinkPHP 请求/响应均为真实类。
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return \ImCallbackFixture::$config[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
use app\api\controller\ImController;
|
||||
use app\api\http\middleware\LoginMiddleware;
|
||||
use app\common\service\ImCallbackSignature;
|
||||
|
||||
final class ImCallbackFixture
|
||||
{
|
||||
public const TOKEN = 'fictional-unit-test-token';
|
||||
public static array $config = ['im.callback_token' => self::TOKEN, 'project.trtc.sdkAppId' => 1400000000];
|
||||
public static array $archived = [];
|
||||
public static array $logs = [];
|
||||
public static bool $throwArchive = false;
|
||||
public static bool $throwLog = false;
|
||||
public static int $inserted = 1;
|
||||
|
||||
public static function archiveImCallbackMessage(array $payload): int
|
||||
{
|
||||
if (self::$throwArchive) {
|
||||
throw new \RuntimeException('patient-private-message-and-SQL-must-not-leak');
|
||||
}
|
||||
self::$archived[] = $payload;
|
||||
return self::$inserted;
|
||||
}
|
||||
|
||||
public static function error(string $message): void
|
||||
{
|
||||
if (self::$throwLog) throw new \RuntimeException('log storage unavailable');
|
||||
self::$logs[] = $message;
|
||||
}
|
||||
|
||||
public function getUserInfo(mixed $token): never
|
||||
{
|
||||
throw new \RuntimeException('Callback must not query session cache/database');
|
||||
}
|
||||
}
|
||||
|
||||
class_alias(ImCallbackFixture::class, 'app\\adminapi\\logic\\tcm\\DiagnosisLogic');
|
||||
class_alias(ImCallbackFixture::class, 'think\\facade\\Log');
|
||||
class_alias(ImCallbackFixture::class, 'app\\common\\cache\\UserTokenCache');
|
||||
|
||||
$assertions = 0;
|
||||
function imCallbackExpect(bool $condition, string $message): void
|
||||
{
|
||||
global $assertions;
|
||||
$assertions++;
|
||||
if (!$condition) throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
function imCallbackQuery(?int $timestamp = null): array
|
||||
{
|
||||
$timestamp ??= time();
|
||||
return [
|
||||
'SdkAppid' => '1400000000',
|
||||
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
|
||||
'RequestTime' => (string) $timestamp,
|
||||
'Sign' => hash('sha256', ImCallbackFixture::TOKEN . $timestamp),
|
||||
];
|
||||
}
|
||||
|
||||
function imCallbackBody(): array
|
||||
{
|
||||
return [
|
||||
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
|
||||
'From_Account' => 'patient_1001',
|
||||
'To_Account' => 'doctor_2001',
|
||||
'MsgSeq' => 7,
|
||||
'MsgRandom' => 8,
|
||||
'MsgTime' => 1700000000,
|
||||
'MsgKey' => '7_8_1700000000',
|
||||
'SendMsgResult' => 0,
|
||||
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'synthetic-test-message']]],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:ImController,1:\think\Request} */
|
||||
function imCallbackController(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): array
|
||||
{
|
||||
$request = (new \think\Request())
|
||||
->withGet($query ?? imCallbackQuery())
|
||||
->withInput($body ?? json_encode(imCallbackBody(), JSON_THROW_ON_ERROR))
|
||||
->withServer(['REQUEST_METHOD' => $method])
|
||||
->withHeader($headers);
|
||||
$request->setAction('messageNotify');
|
||||
$controller = (new \ReflectionClass(ImController::class))->newInstanceWithoutConstructor();
|
||||
(new \ReflectionProperty(\app\BaseController::class, 'request'))->setValue($controller, $request);
|
||||
$request->controllerObject = $controller;
|
||||
return [$controller, $request];
|
||||
}
|
||||
|
||||
function imCallbackResponse(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): \think\response\Json
|
||||
{
|
||||
[$controller, $request] = imCallbackController($query, $body, $method, $headers);
|
||||
return (new LoginMiddleware())->handle($request, static fn () => $controller->messageNotify());
|
||||
}
|
||||
|
||||
function imCallbackReject(int $status, ?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): void
|
||||
{
|
||||
$before = count(ImCallbackFixture::$archived);
|
||||
$response = imCallbackResponse($query, $body, $method, $headers);
|
||||
imCallbackExpect($response->getCode() === $status && $response->getData()['ActionStatus'] === 'FAIL', 'handler rejects with Tencent FAIL and HTTP ' . $status);
|
||||
imCallbackExpect(count(ImCallbackFixture::$archived) === $before, 'rejected requests never reach archiving');
|
||||
}
|
||||
|
||||
// 官方文档签名向量与时间窗口边界。
|
||||
imCallbackExpect(ImCallbackSignature::verify('xxxxyyyy', '1669872112', '17773bc39a671d7b9aa835458704d2a6db81360a5940292b587d6d760d484061', 1669872112), 'official SHA256 concatenation vector matches');
|
||||
foreach ([-60, -59, 0, 59, 60] as $offset) {
|
||||
$query = imCallbackQuery(1700000000 + $offset);
|
||||
imCallbackExpect(ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'timestamps within the inclusive minute window pass');
|
||||
}
|
||||
foreach ([-61, 61] as $offset) {
|
||||
$query = imCallbackQuery(1700000000 + $offset);
|
||||
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'expired or future timestamps beyond one minute fail');
|
||||
}
|
||||
foreach (['', ' ', [], null, 1.5, '-1', '1e9', '1700000000\n'] as $value) {
|
||||
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $value, str_repeat('a', 64), 1700000000), 'malformed RequestTime fails closed');
|
||||
}
|
||||
foreach (['', ' ', [], null, str_repeat('a', 63), str_repeat('g', 64)] as $value) {
|
||||
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, '1700000000', $value, 1700000000), 'malformed Sign fails closed');
|
||||
}
|
||||
imCallbackExpect(!ImCallbackSignature::verify('', '1700000000', hash('sha256', '1700000000'), 1700000000), 'empty configured token cannot authenticate');
|
||||
imCallbackExpect(!ImCallbackSignature::verify(' ', '1700000000', hash('sha256', ' 1700000000'), 1700000000), 'blank configured token cannot authenticate');
|
||||
|
||||
$success = imCallbackResponse();
|
||||
imCallbackExpect($success->getCode() === 200 && $success->getData() === ['ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => ''], 'authenticated actual handler returns the exact Tencent success envelope');
|
||||
imCallbackExpect(ImCallbackFixture::$archived === [imCallbackBody()], 'entire authenticated payload reaches archiveImCallbackMessage');
|
||||
$successWithTokenHeader = imCallbackResponse(headers: ['token' => 'fictional-unrelated-user-token']);
|
||||
imCallbackExpect($successWithTokenHeader->getCode() === 200, 'callback bypasses login session lookup even with an incidental token header');
|
||||
ImCallbackFixture::$inserted = 0;
|
||||
imCallbackExpect(imCallbackResponse()->getCode() === 200, 'idempotent duplicate or safely ignored patient pair acknowledges success');
|
||||
|
||||
imCallbackReject(405, method: 'GET');
|
||||
imCallbackReject(405, method: 'GET', headers: ['x-http-method-override' => 'POST']);
|
||||
imCallbackReject(413, headers: ['content-length' => '1048577']);
|
||||
imCallbackReject(413, body: str_repeat('x', 1048577));
|
||||
foreach (['[', '[]', 'null', '"string"', '{}', '{"CallbackCommand":123}'] as $body) imCallbackReject(400, body: $body);
|
||||
imCallbackReject(400, body: str_repeat('{"nested":', 66) . '0' . str_repeat('}', 66));
|
||||
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => 'Other.Callback']));
|
||||
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => []]));
|
||||
foreach ([null, [], '1400000001', '01400000000'] as $appId) imCallbackReject(403, query: array_replace(imCallbackQuery(), ['SdkAppid' => $appId]));
|
||||
imCallbackReject(403, query: array_replace(imCallbackQuery(), ['Sign' => str_repeat('0', 64)]));
|
||||
imCallbackReject(403, query: imCallbackQuery(time() - 120));
|
||||
imCallbackReject(403, query: imCallbackQuery(time() + 120));
|
||||
$noSign = imCallbackQuery();
|
||||
unset($noSign['Sign']);
|
||||
imCallbackReject(403, query: $noSign);
|
||||
|
||||
ImCallbackFixture::$config['im.callback_token'] = '';
|
||||
imCallbackReject(503);
|
||||
ImCallbackFixture::$config['im.callback_token'] = ImCallbackFixture::TOKEN;
|
||||
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 0;
|
||||
imCallbackReject(503);
|
||||
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 1400000000;
|
||||
|
||||
$before = count(ImCallbackFixture::$archived);
|
||||
$unknown = imCallbackResponse(array_replace(imCallbackQuery(), ['CallbackCommand' => 'State.StateChange']), '{"CallbackCommand":"State.StateChange"}');
|
||||
imCallbackExpect($unknown->getCode() === 200 && count(ImCallbackFixture::$archived) === $before, 'other authenticated callbacks are safely ignored');
|
||||
$noQueryCommand = imCallbackQuery();
|
||||
unset($noQueryCommand['CallbackCommand']);
|
||||
imCallbackExpect(imCallbackResponse($noQueryCommand)->getCode() === 200, 'body command is accepted when optional query command is absent');
|
||||
ImCallbackFixture::$throwArchive = true;
|
||||
$failedArchive = imCallbackResponse();
|
||||
imCallbackExpect($failedArchive->getCode() === 500 && $failedArchive->getData()['ActionStatus'] === 'FAIL', 'archive exception returns HTTP 500 and Tencent FAIL rather than OK');
|
||||
imCallbackExpect(ImCallbackFixture::$logs === ['IM callback archive failed'], 'logging contains neither patient body nor exception SQL nor signature');
|
||||
imCallbackExpect(!str_contains(json_encode($failedArchive->getData()), 'patient-private'), 'response cannot leak archive exception contents');
|
||||
ImCallbackFixture::$throwLog = true;
|
||||
imCallbackExpect(imCallbackResponse()->getCode() === 500, 'logging failure does not replace the Tencent archive failure response');
|
||||
|
||||
// 精确免登录分支不扩散到同一控制器的其他 action。
|
||||
[$otherActionController, $otherActionRequest] = imCallbackController();
|
||||
$otherActionRequest->setAction('otherAction');
|
||||
$nextCalled = false;
|
||||
(new LoginMiddleware())->handle($otherActionRequest, static function () use (&$nextCalled): void { $nextCalled = true; });
|
||||
imCallbackExpect(!$nextCalled, 'noncallback action remains behind ordinary session middleware');
|
||||
|
||||
echo "IM callback signature, actual handler and login middleware: {$assertions} assertions passed (no database/network)\n";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 使用真实命令与定时调度器,内存替代业务逻辑、Console facade、任务模型。
|
||||
final class ImCommandLogicFixture
|
||||
{
|
||||
public static array $result = ['diagnoses' => 1, 'inserted' => 2, 'errors' => []];
|
||||
public static function syncImChatArchiveBatch($days, $limit, $id): array { return self::$result; }
|
||||
}
|
||||
final class ImCommandConsoleFixture
|
||||
{
|
||||
public static bool $fail = true;
|
||||
public static function call($name, $params = []): void
|
||||
{
|
||||
if (self::$fail) throw new RuntimeException('云端暂不可用');
|
||||
}
|
||||
}
|
||||
final class ImCommandTaskFixture
|
||||
{
|
||||
public static array $writes = [];
|
||||
public static function where($key, $value): self { return new self(); }
|
||||
public function update(array $data): void { self::$writes[] = $data; }
|
||||
}
|
||||
final class ImCommandLogFixture { public static function error($message): void {} }
|
||||
class_alias(ImCommandLogicFixture::class, 'app\adminapi\logic\tcm\DiagnosisLogic');
|
||||
class_alias(ImCommandConsoleFixture::class, 'think\facade\Console');
|
||||
class_alias(ImCommandTaskFixture::class, 'app\common\model\Crontab');
|
||||
class_alias(ImCommandLogFixture::class, 'think\facade\Log');
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function imCommandExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$task = ['id' => 3, 'params' => '--since-days=0 --limit=200', 'command' => 'sync_im_chat_archive', 'max_time' => 0];
|
||||
\app\common\command\Crontab::start($task);
|
||||
imCommandExpect(ImCommandTaskFixture::$writes[0]['error'] === '云端暂不可用', 'sync failure remains visible in scheduler');
|
||||
imCommandExpect(ImCommandTaskFixture::$writes[0]['status'] === \app\common\enum\CrontabEnum::START, 'IM sync retries next scheduled round');
|
||||
ImCommandTaskFixture::$writes = [];
|
||||
\app\common\command\Crontab::start(array_merge($task, ['command' => 'unrelated_command']));
|
||||
imCommandExpect(ImCommandTaskFixture::$writes[0]['status'] === \app\common\enum\CrontabEnum::ERROR, 'other command failure behavior is unchanged');
|
||||
ImCommandTaskFixture::$writes = [];
|
||||
ImCommandConsoleFixture::$fail = false;
|
||||
\app\common\command\Crontab::start($task);
|
||||
imCommandExpect(ImCommandTaskFixture::$writes[0]['error'] === '', 'successful round clears previous error');
|
||||
|
||||
$command = new \app\common\command\SyncImChatArchive();
|
||||
$input = new \think\console\Input([]);
|
||||
$output = new \think\console\Output('buffer');
|
||||
ImCommandLogicFixture::$result['errors'] = ['doctor fixture unavailable'];
|
||||
try {
|
||||
$command->run($input, $output);
|
||||
throw new RuntimeException('command silently succeeded');
|
||||
} catch (RuntimeException $e) {
|
||||
imCommandExpect(str_contains($e->getMessage(), 'IM 聊天归档未完全同步'), 'command propagates failure even though Console::call discards return codes');
|
||||
}
|
||||
ImCommandLogicFixture::$result['errors'] = [];
|
||||
imCommandExpect($command->run(new \think\console\Input([]), new \think\console\Output('buffer')) === 0, 'successful command returns zero');
|
||||
echo "IM archive command and scheduler propagation: OK (no database/network)\n";
|
||||
@@ -0,0 +1,548 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ImArchiveTest {
|
||||
/** Strict in-memory adapters: unexpected persistence operations fail closed. */
|
||||
final class Store
|
||||
{
|
||||
public static array $tables = [];
|
||||
public static array $cache = [];
|
||||
public static array $events = [];
|
||||
public static int $clock = 1000;
|
||||
public static int $executeCount = 0;
|
||||
public static int $failOnExecute = 0;
|
||||
public static bool $failUpdate = false;
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$tables = [
|
||||
'diagnosis' => [
|
||||
['id' => 101, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
|
||||
['id' => 102, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
|
||||
['id' => 201, 'patient_id' => 502, 'patient_name' => '患者乙', 'assistant_id' => 8, 'delete_time' => null],
|
||||
],
|
||||
'messages' => [],
|
||||
'admin' => [['id' => 7, 'name' => '医生甲'], ['id' => 8, 'name' => '医生乙']],
|
||||
'appointment' => [],
|
||||
'admin_role' => [['admin_id' => 7, 'role_id' => 1]],
|
||||
];
|
||||
self::$cache = self::$events = [];
|
||||
self::$clock = 1000;
|
||||
self::$executeCount = self::$failOnExecute = 0;
|
||||
self::$failUpdate = false;
|
||||
\app\common\service\TencentImService::$responses = [];
|
||||
\app\common\service\TencentImService::$requests = [];
|
||||
\app\common\service\TencentImService::$checkRequests = [];
|
||||
\app\common\service\TencentImService::$missingAccounts = [];
|
||||
\app\common\service\TencentImService::$checkFailure = null;
|
||||
}
|
||||
}
|
||||
|
||||
final class Rows
|
||||
{
|
||||
public function __construct(private array $rows) {}
|
||||
public function toArray(): array { return $this->rows; }
|
||||
}
|
||||
|
||||
final class Query
|
||||
{
|
||||
private array $filters = [];
|
||||
private array $orders = [];
|
||||
public function __construct(private string $table) {}
|
||||
|
||||
public function where(string $field, $operator, $value = null): self
|
||||
{
|
||||
if (func_num_args() === 2) {
|
||||
$value = $operator;
|
||||
$operator = '=';
|
||||
}
|
||||
if ($operator !== '=') throw new \RuntimeException('Unexpected query operator: ' . $operator);
|
||||
$this->filters[] = static fn (array $row): bool => ($row[$field] ?? null) === $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereIn(string $field, array $values): self
|
||||
{
|
||||
$this->filters[] = static fn (array $row): bool => in_array($row[$field] ?? null, $values, true);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function order(string $field, string $direction): self
|
||||
{
|
||||
$this->orders[] = [$field, $direction];
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function rows(): array
|
||||
{
|
||||
if (!array_key_exists($this->table, Store::$tables)) throw new \RuntimeException('Unexpected table: ' . $this->table);
|
||||
$rows = array_values(array_filter(Store::$tables[$this->table], function (array $row): bool {
|
||||
foreach ($this->filters as $filter) if (!$filter($row)) return false;
|
||||
return true;
|
||||
}));
|
||||
if ($this->orders) {
|
||||
usort($rows, function (array $left, array $right): int {
|
||||
foreach ($this->orders as [$field, $direction]) {
|
||||
$comparison = ($left[$field] ?? null) <=> ($right[$field] ?? null);
|
||||
if ($comparison !== 0) return $direction === 'desc' ? -$comparison : $comparison;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function select(): Rows { return new Rows($this->rows()); }
|
||||
public function find(): ?array { return $this->rows()[0] ?? null; }
|
||||
public function column(string $field, string $key = ''): array
|
||||
{
|
||||
return $key === '' ? array_column($this->rows(), $field) : array_column($this->rows(), $field, $key);
|
||||
}
|
||||
|
||||
public function update(array $data): int
|
||||
{
|
||||
if (Store::$failUpdate) throw new \RuntimeException('archive repair failed');
|
||||
if ($this->table !== 'messages' || array_keys($data) !== ['msg_type', 'text', 'raw_elem_type', 'image_url', 'file_url', 'file_name']) {
|
||||
throw new \RuntimeException('Unexpected archive update');
|
||||
}
|
||||
$ids = array_column($this->rows(), 'id');
|
||||
foreach (Store::$tables[$this->table] as &$row) {
|
||||
if (in_array($row['id'], $ids, true)) $row = array_replace($row, $data);
|
||||
}
|
||||
unset($row);
|
||||
Store::$events[] = ['repair', $ids];
|
||||
return count($ids);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Model
|
||||
{
|
||||
protected const TABLE = '';
|
||||
public static function where(...$args): Query { return (new Query(static::TABLE))->where(...$args); }
|
||||
public static function whereIn(...$args): Query { return (new Query(static::TABLE))->whereIn(...$args); }
|
||||
public function getTable(): string { return 'archive_test_messages'; }
|
||||
}
|
||||
|
||||
final class Database
|
||||
{
|
||||
public function name(string $table): Query { return new Query($table); }
|
||||
public function execute(string $sql, array $bindings): int
|
||||
{
|
||||
Store::$executeCount++;
|
||||
if (Store::$executeCount === Store::$failOnExecute) throw new \RuntimeException('archive write failed');
|
||||
if (!preg_match('/^INSERT INTO `archive_test_messages` \(([^)]+)\) VALUES /', $sql, $match)
|
||||
|| !str_ends_with($sql, ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`')) {
|
||||
throw new \RuntimeException('Unexpected archive SQL; only explicit duplicate-key no-op is supported');
|
||||
}
|
||||
$columns = array_map(static fn (string $column): string => trim($column, '`'), explode(',', $match[1]));
|
||||
if (count($bindings) % count($columns) !== 0) throw new \RuntimeException('Invalid archive SQL bindings');
|
||||
$inserted = 0;
|
||||
foreach (array_chunk($bindings, count($columns)) as $values) {
|
||||
$row = array_combine($columns, $values);
|
||||
$exists = false;
|
||||
foreach (Store::$tables['messages'] as $existing) {
|
||||
if ($existing['msg_id'] === $row['msg_id']) { $exists = true; break; }
|
||||
}
|
||||
if ($exists) continue;
|
||||
$row['id'] = count(Store::$tables['messages']) + 1;
|
||||
Store::$tables['messages'][] = $row;
|
||||
$inserted++;
|
||||
}
|
||||
Store::$events[] = ['archive', $inserted];
|
||||
return $inserted;
|
||||
}
|
||||
}
|
||||
|
||||
final class Cache
|
||||
{
|
||||
public function get(string $key, $default = null) { return Store::$cache[$key] ?? $default; }
|
||||
public function set(string $key, $value, int $ttl = 0): bool
|
||||
{
|
||||
Store::$events[] = ['cache', $key];
|
||||
Store::$cache[$key] = $value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\model\tcm {
|
||||
class Diagnosis extends \ImArchiveTest\Model { protected const TABLE = 'diagnosis'; }
|
||||
class ImChatMessage extends \ImArchiveTest\Model { protected const TABLE = 'messages'; }
|
||||
}
|
||||
namespace app\common\model\auth {
|
||||
class Admin extends \ImArchiveTest\Model { protected const TABLE = 'admin'; }
|
||||
}
|
||||
namespace app\common\model\doctor {
|
||||
class Appointment extends \ImArchiveTest\Model { protected const TABLE = 'appointment'; }
|
||||
}
|
||||
namespace app\common\service {
|
||||
/** Pager remains real; this adapter cannot make HTTP requests. */
|
||||
class TencentImService
|
||||
{
|
||||
public static array $responses = [];
|
||||
public static array $requests = [];
|
||||
public static array $checkRequests = [];
|
||||
public static array $missingAccounts = [];
|
||||
public static ?\Throwable $checkFailure = null;
|
||||
public function checkAccounts(array $accounts): array
|
||||
{
|
||||
self::$checkRequests[] = $accounts;
|
||||
if (count($accounts) > 100) throw new \RuntimeException('account batch exceeds 100');
|
||||
if (self::$checkFailure) throw self::$checkFailure;
|
||||
return ['existing' => array_values(array_diff($accounts, self::$missingAccounts)),
|
||||
'missing' => array_values(array_intersect($accounts, self::$missingAccounts))];
|
||||
}
|
||||
public function adminGetRoamMsg(string $operatorAccount, string $peerAccount, int $maxCnt = 100,
|
||||
int $minTime = 0, int $maxTime = 4294967295, ?string $lastMsgKey = null, ?int $lastMsgTime = null): array
|
||||
{
|
||||
self::$requests[] = compact('operatorAccount', 'peerAccount', 'maxCnt', 'minTime', 'maxTime', 'lastMsgKey', 'lastMsgTime');
|
||||
if (self::$responses === []) throw new \RuntimeException('Unexpected additional IM request');
|
||||
$response = array_shift(self::$responses);
|
||||
if ($response instanceof \Throwable) throw $response;
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace app\adminapi\logic\tcm {
|
||||
// Make checkpoint timing deterministic without replacing any DiagnosisLogic method.
|
||||
function time(): int { return \ImArchiveTest\Store::$clock; }
|
||||
}
|
||||
|
||||
namespace {
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\service\ImChatSyncSession;
|
||||
use app\common\service\ImRoamMessagePager;
|
||||
use app\common\service\TencentImService;
|
||||
use ImArchiveTest\Store;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// No initialize(): no environment configuration, network, or business DB connection.
|
||||
$testApp = new think\App();
|
||||
$testApp->instance('think\DbManager', new ImArchiveTest\Database());
|
||||
$testApp->instance('cache', new ImArchiveTest\Cache());
|
||||
$testApp->instance('log', new Psr\Log\NullLogger());
|
||||
think\facade\Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'archive-test-key']], 'project');
|
||||
|
||||
function archiveExpect(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) throw new RuntimeException($message);
|
||||
}
|
||||
function archiveFails(callable $action, string $expected): void
|
||||
{
|
||||
try { $action(); }
|
||||
catch (Throwable $exception) {
|
||||
archiveExpect(str_contains($exception->getMessage(), $expected), 'Expected ' . $expected . '; got ' . $exception->getMessage());
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected failure: ' . $expected);
|
||||
}
|
||||
function archiveInvoke(string $method, ...$args)
|
||||
{
|
||||
return (new ReflectionMethod(DiagnosisLogic::class, $method))->invoke(null, ...$args);
|
||||
}
|
||||
function archiveRaw(string $key, int $time, int $patientId = 501, bool $reverse = false): array
|
||||
{
|
||||
return [
|
||||
'From_Account' => $reverse ? 'patient_' . $patientId : 'doctor_7',
|
||||
'To_Account' => $reverse ? 'doctor_7' : 'patient_' . $patientId,
|
||||
'MsgTimeStamp' => $time, 'MsgSeq' => 11, 'MsgRandom' => 22, 'MsgKey' => $key,
|
||||
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'message ' . $key]]],
|
||||
];
|
||||
}
|
||||
function archivePage(bool $completed, ?int $time, ?string $key, array $messages): array
|
||||
{
|
||||
return ['success' => true, 'complete' => $completed ? 1 : 0, 'msgList' => $messages,
|
||||
'lastMsgTime' => $time, 'lastMsgKey' => $key, 'rawErrorCode' => 0, 'error' => ''];
|
||||
}
|
||||
function archiveStored(string $id, int $patientId, int $diagnosisId, string $from, string $to, int $time): array
|
||||
{
|
||||
return ['id' => count(Store::$tables['messages']) + 1, 'msg_id' => $id, 'patient_id' => $patientId,
|
||||
'diagnosis_id' => $diagnosisId, 'from_account' => $from, 'to_account' => $to,
|
||||
'msg_time' => $time, 'doctor_peer_account' => 'doctor_7', 'text' => $id];
|
||||
}
|
||||
// Existing archive/pagination cases advance past account-only setup requests.
|
||||
function archiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
|
||||
{
|
||||
do {
|
||||
$result = DiagnosisLogic::syncImChatArchiveStep($diagnosisId, $adminId, $token, $currentPeer);
|
||||
$token = $result['sync_token'];
|
||||
$state = Store::$cache['im_chat_sync:' . $token];
|
||||
} while (!$result['completed'] && (!($state['accounts_verified'] ?? false) || !isset($state['active_index'])));
|
||||
return $result;
|
||||
}
|
||||
|
||||
function archiveFinishPatientSide(string $token): array
|
||||
{
|
||||
TencentImService::$responses[] = archivePage(true, null, null, []);
|
||||
return archiveStep(101, 7, $token, true);
|
||||
}
|
||||
archiveExpect(str_ends_with((new ReflectionClass(DiagnosisLogic::class))->getFileName(), 'DiagnosisLogic.php'), 'Use the actual diagnosis logic');
|
||||
archiveExpect(str_ends_with((new ReflectionClass(ImRoamMessagePager::class))->getFileName(), 'ImRoamMessagePager.php'), 'Use the actual pager');
|
||||
|
||||
// Reading a patient's archive spans diagnoses, but never trusts patient_id without checking the accounts.
|
||||
Store::reset();
|
||||
Store::$tables['messages'][] = archiveStored('old', 501, 101, 'doctor_7', 'patient_501', 100);
|
||||
Store::$tables['messages'][] = archiveStored('new', 501, 102, 'patient_501', 'doctor_8', 200);
|
||||
Store::$tables['messages'][] = archiveStored('corrupt-patient-column', 501, 101, 'doctor_7', 'patient_502', 150);
|
||||
Store::$tables['messages'][] = archiveStored('other', 502, 201, 'doctor_7', 'patient_502', 100);
|
||||
$old = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
|
||||
$new = DiagnosisLogic::getImChatMessagesForDiagnosis(102, true);
|
||||
$other = DiagnosisLogic::getImChatMessagesForDiagnosis(201, true);
|
||||
archiveExpect(array_column($old['lists'], 'msg_id') === ['old', 'new'] && array_column($new['lists'], 'msg_id') === ['old', 'new'], 'Old and new diagnoses share the same patient archive');
|
||||
archiveExpect(array_unique(array_column($old['lists'], 'diagnosis_id')) === [101]
|
||||
&& array_unique(array_column($new['lists'], 'diagnosis_id')) === [102], 'Response rows carry the currently authorized diagnosis');
|
||||
archiveExpect(array_column($other['lists'], 'msg_id') === ['other'], 'Another patient cannot receive shared or mislabeled rows');
|
||||
archiveExpect($new['lists'][0]['from_staff_name'] === '医生甲' && TencentImService::$requests === [], 'Archive-only reads enrich staff names without network');
|
||||
|
||||
// Callback MsgTime + MsgKey and roam MsgTimeStamp/MsgRandom identify the same message.
|
||||
Store::reset();
|
||||
$roam = archiveRaw('11_22_100', 100);
|
||||
$callback = $roam;
|
||||
$callback['SendMsgResult'] = 0;
|
||||
$callback['MsgTime'] = $callback['MsgTimeStamp'];
|
||||
unset($callback['MsgTimeStamp'], $callback['MsgRandom'], $callback['MsgSeq']);
|
||||
$normalized = archiveInvoke('normalizeTimMessage', $roam);
|
||||
archiveExpect($normalized['msg_id'] === archiveInvoke('normalizeTimMessage', $callback)['msg_id'], 'Callback and roam use canonical message identity');
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Callback archives to the latest patient diagnosis');
|
||||
$failedCallback = array_replace($callback, ['MsgKey' => 'failed-send', 'SendMsgResult' => 90001]);
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($failedCallback) === 0 && count(Store::$tables['messages']) === 1, 'Failed sending callback is not archived');
|
||||
foreach ([null, '0', true] as $invalidSendResult) {
|
||||
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage(array_replace($callback, ['SendMsgResult' => $invalidSendResult])), '发送结果');
|
||||
}
|
||||
$missingSendResult = $callback;
|
||||
unset($missingSendResult['SendMsgResult']);
|
||||
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($missingSendResult), '发送结果');
|
||||
archiveExpect(archiveInvoke('persistImChatArchiveRows', 101, 501, [$normalized]) === 0
|
||||
&& DiagnosisLogic::archiveImCallbackMessage($callback) === 0, 'Roam and repeated callback are idempotent');
|
||||
archiveExpect(count(Store::$tables['messages']) === 1 && Store::$tables['messages'][0]['diagnosis_id'] === 102, 'Duplicate writes do not mutate existing archive provenance');
|
||||
archiveExpect($normalized['msg_id'] !== archiveInvoke('normalizeTimMessage', archiveRaw('11_22_100', 100, 502))['msg_id'], 'Canonical identity includes both accounts to isolate patients');
|
||||
archiveFails(static fn () => archiveInvoke('persistImChatArchiveRows', 101, 501, [archiveInvoke('normalizeTimMessage', archiveRaw('wrong', 100, 502))]), '不属于当前患者');
|
||||
|
||||
// Legacy seq_random_from keys are reused only when patient, endpoints and time all agree.
|
||||
Store::reset();
|
||||
Store::$tables['messages'][] = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 0 && count(Store::$tables['messages']) === 1, 'Legacy key remains deduplicated even when callback omits MsgRandom');
|
||||
foreach ([[502, 'patient_502', 100], [501, 'patient_502', 100], [501, 'patient_501', 99]] as [$legacyPatient, $legacyTo, $legacyTime]) {
|
||||
Store::reset();
|
||||
$legacy = archiveStored('11_22_doctor_7', $legacyPatient, 201, 'doctor_7', $legacyTo, $legacyTime);
|
||||
Store::$tables['messages'][] = $legacy;
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Colliding legacy key does not suppress a different patient/time/account message');
|
||||
archiveExpect(Store::$tables['messages'][0] === $legacy && Store::$tables['messages'][1]['msg_id'] === $normalized['msg_id'], 'Legacy collision preserves the existing row and inserts the canonical key');
|
||||
}
|
||||
|
||||
// Composite messages retain every element and repair truncated legacy rows without changing ownership.
|
||||
Store::reset();
|
||||
$composite = $callback;
|
||||
$composite['MsgBody'][] = ['MsgType' => 'TIMImageElem', 'MsgContent' => ['ImageInfoArray' => [['URL' => 'https://example.invalid/image.jpg']]]];
|
||||
$composite['MsgBody'][] = ['MsgType' => 'TIMFileElem', 'MsgContent' => ['Url' => 'https://example.invalid/report.pdf', 'FileName' => '报告.pdf']];
|
||||
$compositeRow = archiveInvoke('normalizeTimMessage', $composite);
|
||||
archiveExpect($compositeRow['msg_type'] === 'composite' && $compositeRow['raw_elem_type'] === 'TIMMultiElem', 'Multiple message elements use the composite archive representation');
|
||||
$parts = json_decode($compositeRow['text'], true, 512, JSON_THROW_ON_ERROR);
|
||||
archiveExpect(array_column($parts, 'msg_type') === ['text', 'image', 'file'] && $parts[2]['file_name'] === '报告.pdf', 'Text, image and file elements retain their order and content');
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 1, 'Composite callback archives as one canonical message');
|
||||
$compositeRead = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
|
||||
archiveExpect($compositeRead['lists'][0]['parts'] === $parts, 'Archive reads restore every composite element for rendering');
|
||||
|
||||
Store::reset();
|
||||
$truncated = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
|
||||
$truncated['msg_type'] = 'text';
|
||||
$truncated['text'] = 'first element only';
|
||||
Store::$tables['messages'][] = $truncated;
|
||||
$unrelated = archiveStored('other-patient-legacy', 502, 201, 'doctor_7', 'patient_502', 100);
|
||||
Store::$tables['messages'][] = $unrelated;
|
||||
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 0 && count(Store::$tables['messages']) === 2, 'Repair reuses a matched legacy key without creating a duplicate');
|
||||
$repaired = Store::$tables['messages'][0];
|
||||
archiveExpect($repaired['msg_type'] === 'composite' && json_decode($repaired['text'], true) === $parts
|
||||
&& $repaired['diagnosis_id'] === 101 && $repaired['msg_id'] === $truncated['msg_id']
|
||||
&& Store::$tables['messages'][1] === $unrelated, 'Legacy content repair preserves archive provenance and cannot modify another patient');
|
||||
$repairCount = count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair'));
|
||||
DiagnosisLogic::archiveImCallbackMessage($composite);
|
||||
archiveExpect(count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair')) === $repairCount, 'Already repaired composite content is idempotent');
|
||||
Store::$tables['messages'][0] = $truncated;
|
||||
Store::$failUpdate = true;
|
||||
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($composite), 'archive repair failed');
|
||||
archiveExpect(Store::$tables['messages'][0] === $truncated, 'A failed legacy repair is not swallowed as successful archive');
|
||||
|
||||
// Current-peer scope is server-selected; token reuse is bound to admin, diagnosis and patient.
|
||||
Store::reset();
|
||||
TencentImService::$responses = [archivePage(false, 200, 'first', [archiveRaw('first', 200)])];
|
||||
$first = archiveStep(101, 7, '', true);
|
||||
$token = $first['sync_token'];
|
||||
$sessionKey = 'im_chat_sync:' . $token;
|
||||
$checkpointKey = 'im_chat_complete_v1:501:doctor_7';
|
||||
$saved = Store::$cache[$sessionKey];
|
||||
archiveExpect(!$first['completed'] && $first['inserted'] === 1 && $saved['cursor']['max_time'] === 200, 'A persisted non-final page advances its full cursor');
|
||||
archiveExpect($saved['accounts'] === ['doctor_7'] && $saved['admin_id'] === 7 && $saved['diagnosis_id'] === 101 && $saved['patient_id'] === 501, 'Current scope records its exact authorized identity');
|
||||
archiveExpect(!isset(Store::$cache[$checkpointKey]) && TencentImService::$requests[0]['minTime'] === 0, 'Partial scan has no complete checkpoint and starts from the beginning');
|
||||
$requestCount = count(TencentImService::$requests);
|
||||
archiveFails(static fn () => archiveStep(101, 8, $token, true), '失效');
|
||||
archiveFails(static fn () => archiveStep(102, 7, $token, true), '失效');
|
||||
Store::$tables['diagnosis'][0]['patient_id'] = 502;
|
||||
archiveFails(static fn () => archiveStep(101, 7, $token, true), '失效');
|
||||
Store::$tables['diagnosis'][0]['patient_id'] = 501;
|
||||
archiveFails(static fn () => archiveStep(101, 7, 'bad-token', true), '无效');
|
||||
archiveFails(static fn () => archiveStep(101, 7, str_repeat('a', 48), true), '失效');
|
||||
archiveExpect(count(TencentImService::$requests) === $requestCount && Store::$cache[$sessionKey] === $saved, 'Invalid token reuse cannot call IM or change saved progress');
|
||||
Store::$clock = 2000;
|
||||
TencentImService::$responses = [archivePage(true, 199, 'last', [archiveRaw('last', 199, 501, true)])];
|
||||
$doctorSide = archiveStep(101, 7, $token, false);
|
||||
archiveExpect(!$doctorSide['completed'] && Store::$cache[$sessionKey]['side'] === 1
|
||||
&& !isset(Store::$cache[$checkpointKey]), 'Finishing doctor-side pages starts the patient-side scan without a complete checkpoint');
|
||||
TencentImService::$responses = [archivePage(true, 198, 'patient-only', [archiveRaw('first', 200), archiveRaw('patient-only', 198, 501, true)])];
|
||||
$last = archiveStep(101, 7, $token, true);
|
||||
archiveExpect($last['completed'] && $last['errors'] === [] && $last['inserted'] === 3, 'Only both persisted perspectives complete a conversation, including patient-only history');
|
||||
archiveExpect(Store::$cache[$checkpointKey] === 1000, 'Complete checkpoint uses the scan start time, not archive MAX(msg_time) or finish time');
|
||||
archiveExpect(TencentImService::$requests[1]['maxTime'] === 200 && TencentImService::$requests[1]['lastMsgKey'] === 'first'
|
||||
&& TencentImService::$requests[1]['operatorAccount'] === 'doctor_7', 'Token continuation preserves cursor and cannot expand current-peer scope');
|
||||
archiveExpect(TencentImService::$requests[2]['operatorAccount'] === 'patient_501'
|
||||
&& TencentImService::$requests[2]['peerAccount'] === 'doctor_7' && TencentImService::$requests[2]['minTime'] === 0
|
||||
&& TencentImService::$requests[2]['maxTime'] === 4294967295, 'Patient-side scan swaps perspective and restarts the same time range');
|
||||
archiveExpect(count(Store::$tables['messages']) === 3 && Store::$tables['messages'][2]['doctor_peer_account'] === 'doctor_7', 'Both perspectives deduplicate shared messages and preserve doctor attribution');
|
||||
$eventKinds = array_column(Store::$events, 0);
|
||||
archiveExpect($eventKinds === ['cache', 'archive', 'cache', 'archive', 'cache', 'archive', 'cache', 'cache'], 'Both-side page persistence precedes checkpoint and session progress writes');
|
||||
TencentImService::$responses = [archivePage(true, null, null, [])];
|
||||
$incremental = archiveStep(101, 7, '', true);
|
||||
archiveFinishPatientSide($incremental['sync_token']);
|
||||
archiveExpect(TencentImService::$requests[3]['minTime'] === 880 && TencentImService::$requests[4]['minTime'] === 880, 'Only a completed checkpoint can enable the same overlap range for both perspectives');
|
||||
|
||||
// A later-page IM error reports failure and leaves the complete checkpoint untouched.
|
||||
Store::reset();
|
||||
Store::$cache[$checkpointKey] = 250;
|
||||
TencentImService::$responses = [archivePage(false, 300, 'a', [archiveRaw('a', 300)]),
|
||||
['success' => false, 'rawErrorCode' => 91000, 'error' => 'page two unavailable']];
|
||||
$first = archiveStep(101, 7, '', true);
|
||||
$failedDoctorSide = archiveStep(101, 7, $first['sync_token'], true);
|
||||
archiveExpect(!$failedDoctorSide['completed'], 'Doctor-side failure still permits the patient-side attempt');
|
||||
$failed = archiveFinishPatientSide($first['sync_token']);
|
||||
archiveExpect($failed['inserted'] === 1 && count($failed['errors']) === 1 && str_contains($failed['errors'][0], 'page two unavailable'), 'Failed page is visible while prior successfully archived pages remain');
|
||||
archiveExpect(Store::$cache[$checkpointKey] === 250 && count(Store::$tables['messages']) === 1, 'An incomplete conversation preserves the previous complete checkpoint');
|
||||
TencentImService::$responses = [archivePage(true, 299, 'b', [archiveRaw('a', 300), archiveRaw('b', 299)])];
|
||||
$recoveredDoctorSide = archiveStep(101, 7, '', true);
|
||||
$recovered = archiveFinishPatientSide($recoveredDoctorSide['sync_token']);
|
||||
archiveExpect($recovered['completed'] && $recovered['errors'] === [] && $recovered['inserted'] === 1
|
||||
&& count(Store::$tables['messages']) === 2 && TencentImService::$requests[3]['minTime'] === 130, 'Next round backfills from the unchanged checkpoint range and deduplicates the already archived page');
|
||||
|
||||
Store::reset();
|
||||
TencentImService::$responses = [archivePage(true, 300, 'foreign', [archiveRaw('foreign', 300, 502)])];
|
||||
$foreignPage = archiveStep(101, 7, '', true);
|
||||
archiveExpect(count($foreignPage['errors']) === 1 && Store::$tables['messages'] === []
|
||||
&& !isset(Store::$cache[$checkpointKey]), 'A cross-patient cloud page cannot be archived or create a complete checkpoint');
|
||||
|
||||
Store::reset();
|
||||
Store::$cache[$checkpointKey] = 250;
|
||||
TencentImService::$responses = [archivePage(true, 300, 'doctor-only', [archiveRaw('doctor-only', 300)]),
|
||||
['success' => false, 'rawErrorCode' => 91000, 'error' => 'patient perspective unavailable']];
|
||||
$doctorOnly = archiveStep(101, 7, '', true);
|
||||
$patientSideFailure = archiveStep(101, 7, $doctorOnly['sync_token'], true);
|
||||
archiveExpect($patientSideFailure['completed'] && count($patientSideFailure['errors']) === 1
|
||||
&& str_contains($patientSideFailure['errors'][0], '患者侧') && Store::$cache[$checkpointKey] === 250,
|
||||
'A patient-side failure also prevents advancing the complete checkpoint');
|
||||
|
||||
// A DB failure must throw before caching the new page cursor or complete checkpoint.
|
||||
Store::reset();
|
||||
TencentImService::$responses = [archivePage(false, 400, 'db-first', [archiveRaw('db-first', 400)])];
|
||||
$first = archiveStep(101, 7, '', true);
|
||||
$sessionKey = 'im_chat_sync:' . $first['sync_token'];
|
||||
$beforeWriteFailure = Store::$cache[$sessionKey];
|
||||
Store::$failOnExecute = Store::$executeCount + 1;
|
||||
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
|
||||
archiveFails(static fn () => archiveStep(101, 7, $first['sync_token'], true), 'archive write failed');
|
||||
archiveExpect(Store::$cache[$sessionKey] === $beforeWriteFailure && !isset(Store::$cache[$checkpointKey])
|
||||
&& count(Store::$tables['messages']) === 1, 'Failed persistence leaves resumable session cursor and checkpoint unchanged');
|
||||
Store::$failOnExecute = 0;
|
||||
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
|
||||
$retried = archiveStep(101, 7, $first['sync_token'], true);
|
||||
archiveExpect(!$retried['completed'] && $retried['inserted'] === 2 && count(Store::$tables['messages']) === 2
|
||||
&& !isset(Store::$cache[$checkpointKey]), 'Retry archives the failed doctor-side page before starting the patient side');
|
||||
archiveExpect(TencentImService::$requests[1] === TencentImService::$requests[2], 'DB-failed page is retried using the exact previous cursor');
|
||||
archiveExpect(archiveFinishPatientSide($first['sync_token'])['completed'], 'Completion follows successful persistence and both perspective scans');
|
||||
|
||||
// A single cloud page can span several SQL batches; retry keeps the successful first batch idempotent.
|
||||
Store::reset();
|
||||
$bulkMessages = [];
|
||||
for ($index = 0; $index < 81; $index++) $bulkMessages[] = archiveRaw('bulk-' . $index, 600);
|
||||
Store::$failOnExecute = 2;
|
||||
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
|
||||
archiveFails(static fn () => archiveStep(101, 7, '', true), 'archive write failed');
|
||||
archiveExpect(count(Store::$tables['messages']) === 80 && !isset(Store::$cache[$checkpointKey]) && count(Store::$cache) === 1 && array_values(Store::$cache)[0]['cursor'] === [], 'A failed second SQL batch does not publish a completed page or session cursor');
|
||||
Store::$failOnExecute = 0;
|
||||
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
|
||||
$bulkDoctorSide = archiveStep(101, 7, '', true);
|
||||
$bulkRetry = archiveFinishPatientSide($bulkDoctorSide['sync_token']);
|
||||
archiveExpect($bulkRetry['completed'] && $bulkRetry['errors'] === [] && $bulkRetry['inserted'] === 1
|
||||
&& count(Store::$tables['messages']) === 81, 'Retry finishes a partially persisted page without duplicating its first batch');
|
||||
|
||||
Store::reset();
|
||||
Store::$failOnExecute = 1;
|
||||
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($callback), 'archive write failed');
|
||||
TencentImService::$responses = [archivePage(true, 100, 'cli', [archiveRaw('cli', 100)])];
|
||||
Store::$failOnExecute = Store::$executeCount + 1;
|
||||
$cliFailure = DiagnosisLogic::syncImChatArchiveForDiagnosis(101);
|
||||
archiveExpect(str_contains($cliFailure['error'] ?? '', 'archive write failed') && !$cliFailure['skipped_live_empty']
|
||||
&& !isset(Store::$cache[$checkpointKey]), 'CLI sync also reports write failure instead of empty successful synchronization');
|
||||
|
||||
// Session policy continues other peers after a fetch failure, never after a swallowed archive failure.
|
||||
$state = ImChatSyncSession::start(['doctor_7', 'doctor_8', 'doctor_7']);
|
||||
$archiveCalls = 0;
|
||||
$next = ImChatSyncSession::step($state, static function () { throw new RuntimeException('peer unavailable'); },
|
||||
static function () use (&$archiveCalls): int { $archiveCalls++; return 1; });
|
||||
archiveExpect($next['index'] === 0 && $next['side'] === 1 && $next['cursor'] === [] && count($next['errors']) === 1 && $archiveCalls === 0, 'Fetch failure starts the other side without archiving or marking that peer as successful');
|
||||
$nextPeer = ImChatSyncSession::step($next, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
|
||||
archiveExpect($nextPeer['index'] === 1 && $nextPeer['side'] === 0, 'Only finishing both perspectives moves to the next peer');
|
||||
$lastSide = ImChatSyncSession::step($nextPeer, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
|
||||
$finished = ImChatSyncSession::step($lastSide, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
|
||||
archiveExpect(ImChatSyncSession::progress($finished)['completed'] && count($finished['errors']) === 1, 'Other peers can finish while the prior failure remains visible');
|
||||
archiveFails(static fn () => ImChatSyncSession::step($state,
|
||||
static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []],
|
||||
static function (): int { throw new RuntimeException('archive failed'); }), 'archive failed');
|
||||
archiveExpect($state['index'] === 0 && $state['cursor'] === [], 'Archive failure does not mutate the caller state');
|
||||
|
||||
// Check candidates in bounded batches before querying any cloud history.
|
||||
Store::reset();
|
||||
Store::$tables['admin_role'] = array_map(static fn (int $id): array => ['admin_id' => $id, 'role_id' => 1], range(1, 184));
|
||||
TencentImService::$missingAccounts = array_values(array_filter(array_map(static fn (int $id): string => 'doctor_' . $id, range(1, 184)), static fn (string $account): bool => $account !== 'doctor_7'));
|
||||
$checking = DiagnosisLogic::syncImChatArchiveStep(101, 7);
|
||||
archiveExpect($checking['phase'] === 'checking_accounts' && !$checking['completed'] && $checking['checked_accounts'] === 100
|
||||
&& $checking['candidate_accounts'] === 185 && TencentImService::$requests === [], 'First request only validates one batch of 100 accounts');
|
||||
$beforeCheckFailure = Store::$cache['im_chat_sync:' . $checking['sync_token']];
|
||||
TencentImService::$checkFailure = new RuntimeException('account service permission denied', 70001);
|
||||
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']), 'permission denied');
|
||||
archiveExpect(Store::$cache['im_chat_sync:' . $checking['sync_token']] === $beforeCheckFailure && TencentImService::$requests === [], 'Account check failure does not discard unknown accounts or advance progress');
|
||||
TencentImService::$checkFailure = null;
|
||||
$checked = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
|
||||
archiveExpect($checked['phase'] === 'syncing' && $checked['total_peers'] === 1 && $checked['skipped_accounts'] === 183
|
||||
&& $checked['errors'] === [] && TencentImService::$requests === [], 'Only imported doctor accounts become history peers; missing accounts are an informational count');
|
||||
archiveExpect(count(TencentImService::$checkRequests[0]) === 100 && count(TencentImService::$checkRequests[2]) === 85, 'Continuation reuses the uncompleted second batch');
|
||||
TencentImService::$responses = [archivePage(true, 900, 'valid', [archiveRaw('valid', 900)]), archivePage(true, null, null, [])];
|
||||
DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
|
||||
$validFinished = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
|
||||
archiveExpect($validFinished['completed'] && $validFinished['errors'] === [] && $validFinished['inserted'] === 1, 'Valid messages continue syncing despite 183 unregistered staff accounts');
|
||||
archiveExpect(array_column(TencentImService::$requests, 'operatorAccount') === ['doctor_7', 'patient_501']
|
||||
&& array_column(TencentImService::$requests, 'peerAccount') === ['patient_501', 'doctor_7'], 'Missing accounts are never sent to admin_getroammsg');
|
||||
|
||||
Store::reset();
|
||||
Store::$tables['messages'][] = archiveStored('keep-archive', 501, 101, 'doctor_7', 'patient_501', 100);
|
||||
TencentImService::$missingAccounts = ['doctor_7'];
|
||||
$emptyPeers = DiagnosisLogic::syncImChatArchiveStep(101, 7);
|
||||
archiveExpect($emptyPeers['completed'] && $emptyPeers['total_peers'] === 0 && $emptyPeers['skipped_accounts'] === 1 && $emptyPeers['errors'] === [], 'All staff missing completes without flooding errors or fabricating a failed conversation');
|
||||
archiveExpect(count(DiagnosisLogic::getImChatMessagesForDiagnosis(101, true)['lists']) === 1 && TencentImService::$requests === [], 'Missing/deleted cloud accounts do not remove existing archives');
|
||||
TencentImService::$missingAccounts = ['patient_501'];
|
||||
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7), '未找到患者聊天账号');
|
||||
archiveExpect(TencentImService::$requests === [] && count(Store::$tables['messages']) === 1, 'Missing patient yields one actionable configuration error and keeps archived records');
|
||||
|
||||
// In-flight tokens from the previous release also go through validation, instead of repeating stale invalid-account errors.
|
||||
Store::reset();
|
||||
$oldToken = str_repeat('b', 48);
|
||||
Store::$cache['im_chat_sync:' . $oldToken] = array_merge(ImChatSyncSession::start(['doctor_7', 'doctor_8']), [
|
||||
'diagnosis_id' => 101, 'patient_id' => 501, 'admin_id' => 7, 'index' => 1,
|
||||
'inserted' => 3, 'errors' => ['old invalid Operator_Account or Peer_Account'], 'active_index' => 1,
|
||||
]);
|
||||
TencentImService::$missingAccounts = ['doctor_8'];
|
||||
$migrated = DiagnosisLogic::syncImChatArchiveStep(101, 7, $oldToken);
|
||||
archiveExpect($migrated['total_peers'] === 1 && $migrated['inserted'] === 3 && $migrated['errors'] === []
|
||||
&& $migrated['skipped_accounts'] === 1 && TencentImService::$requests === [], 'Old tokens retain archived counts and restart verified peer selection without stale errors');
|
||||
|
||||
echo "IM_CHAT_ARCHIVE_TEST_OK\n";
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\ImRoamMessagePager;
|
||||
use app\common\service\TencentImService;
|
||||
use think\facade\Config;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
// 不 initialize,不读取环境数据库;只有虚构配置,HTTP 始终由替身拦截。
|
||||
$testApp = new think\App();
|
||||
$testApp->instance('log', new Psr\Log\NullLogger());
|
||||
Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'im-pager-test-key']], 'project');
|
||||
|
||||
class RoamHttpFixture extends TencentImService
|
||||
{
|
||||
public array $responses = [];
|
||||
public array $requests = [];
|
||||
|
||||
protected function httpPost(string $url, string $data, int $timeout = 10)
|
||||
{
|
||||
$this->requests[] = ['data' => json_decode($data, true), 'timeout' => $timeout];
|
||||
if ($this->responses === []) {
|
||||
throw new RuntimeException('Unexpected extra HTTP request');
|
||||
}
|
||||
$response = array_shift($this->responses);
|
||||
if ($response instanceof Throwable) {
|
||||
throw $response;
|
||||
}
|
||||
|
||||
return is_array($response) ? json_encode($response, JSON_THROW_ON_ERROR) : $response;
|
||||
}
|
||||
}
|
||||
|
||||
function roamExpect(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function roamFails(callable $action, string $message, int $code = 0): void
|
||||
{
|
||||
try {
|
||||
$action();
|
||||
} catch (RuntimeException $exception) {
|
||||
roamExpect(str_contains($exception->getMessage(), $message), 'Expected error: ' . $message . '; got: ' . $exception->getMessage());
|
||||
roamExpect($exception->getCode() === $code, 'Cloud error code must survive pager failure');
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected page failure: ' . $message);
|
||||
}
|
||||
|
||||
$message = static fn (string $key, int $time, bool $reverse = false): array => [
|
||||
'From_Account' => $reverse ? 'patient_2' : 'doctor_1',
|
||||
'To_Account' => $reverse ? 'doctor_1' : 'patient_2',
|
||||
'MsgKey' => $key,
|
||||
'MsgTimeStamp' => $time,
|
||||
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => $key]]],
|
||||
];
|
||||
$page = static fn (int $complete, ?int $time, ?string $key, array $messages): array => [
|
||||
'ActionStatus' => 'OK', 'ErrorCode' => 0, 'Complete' => $complete,
|
||||
'LastMsgTime' => $time, 'LastMsgKey' => $key, 'MsgCnt' => count($messages), 'MsgList' => $messages,
|
||||
];
|
||||
$service = new RoamHttpFixture();
|
||||
$service->responses = [
|
||||
$page(0, 200, 'a', [$message('a', 200)]),
|
||||
$page(0, 200, 'b', [$message('b', 200, true)]),
|
||||
$page(1, 199, 'c', [$message('c', 199)]),
|
||||
];
|
||||
$first = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2');
|
||||
roamExpect(!$first['completed'] && count($service->requests) === 1, 'One step pulls exactly one page');
|
||||
$second = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']);
|
||||
roamExpect(!$second['completed'] && $second['cursor']['seen_keys'] === ['a', 'b'], 'Different keys allow multiple pages in the same second');
|
||||
$third = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']);
|
||||
roamExpect($third['completed'] && $third['cursor']['seen_keys'] === ['c'], 'History of keys resets only when time moves into an earlier second');
|
||||
roamExpect(array_column(array_merge($first['msgList'], $second['msgList'], $third['msgList']), 'MsgKey') === ['a', 'b', 'c'], 'All pages preserve raw messages in both directions');
|
||||
foreach ($service->requests as $request) {
|
||||
roamExpect($request['timeout'] === 15, 'Each page uses a bounded 15 second timeout');
|
||||
roamExpect($request['data']['MinTime'] === 0, 'Initial full scan does not use the latest archived message as its lower bound');
|
||||
roamExpect(!array_key_exists('LastMsgTime', $request['data']), 'LastMsgTime is never a request field');
|
||||
}
|
||||
roamExpect($service->requests[0]['data']['MaxTime'] === 4294967295 && !isset($service->requests[0]['data']['LastMsgKey']), 'Initial request has the full range and no last key');
|
||||
roamExpect($service->requests[1]['data']['MaxTime'] === 200 && $service->requests[1]['data']['LastMsgKey'] === 'a', 'Second request uses response LastMsgTime as MaxTime');
|
||||
roamExpect($service->requests[2]['data']['MaxTime'] === 200 && $service->requests[2]['data']['LastMsgKey'] === 'b', 'Same-second continuation advances by key');
|
||||
|
||||
$service->responses = [$page(1, null, null, [])];
|
||||
$legacy = $service->adminGetRoamMsg('doctor_1', 'patient_2', 100, 0, 4294967295, 'old-key', 123);
|
||||
$legacyRequest = $service->requests[count($service->requests) - 1]['data'];
|
||||
roamExpect($legacy['success'] && $legacyRequest['MaxTime'] === 123 && !isset($legacyRequest['LastMsgTime']), 'Legacy seven argument calls also use the correct continuation field');
|
||||
|
||||
foreach ([
|
||||
['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later'],
|
||||
['ActionStatus' => 'OK', 'ErrorCode' => 90009, 'ErrorInfo' => 'Cloud permission denied'],
|
||||
] as $error) {
|
||||
$service->responses = [$error];
|
||||
$before = count($service->requests);
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error['ErrorInfo'], $error['ErrorCode']);
|
||||
roamExpect(count($service->requests) === $before + 1, 'Cloud errors are reported without an inline retry');
|
||||
}
|
||||
|
||||
$service->responses = [['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later']];
|
||||
$failedPage = $service->adminGetRoamMsg('doctor_1', 'patient_2');
|
||||
roamExpect(!$failedPage['success'] && $failedPage['complete'] === 0
|
||||
&& $failedPage['error'] === 'Cloud retry later' && $failedPage['rawErrorCode'] === 91000,
|
||||
'Legacy service errors retain the error and code without marking the conversation complete');
|
||||
|
||||
foreach ([
|
||||
['not-json', 'IM响应解析失败'],
|
||||
[false, 'IM接口无响应'],
|
||||
[new RuntimeException('transport timeout'), 'transport timeout'],
|
||||
[['ActionStatus' => 'OK', 'ErrorCode' => 0, 'MsgList' => []], 'Complete'],
|
||||
[array_replace($page(1, null, null, []), ['MsgList' => 'invalid']), 'MsgList'],
|
||||
[array_replace($page(1, null, null, []), ['MsgCnt' => 1]), 'MsgCnt'],
|
||||
[array_replace($page(1, null, null, []), ['LastMsgTime' => '200']), 'LastMsgTime'],
|
||||
[array_replace($page(1, null, null, []), ['LastMsgKey' => ['bad']]), 'LastMsgKey'],
|
||||
[$page(0, null, null, []), 'LastMsgTime/LastMsgKey'],
|
||||
[$page(0, 200, null, []), 'LastMsgTime/LastMsgKey'],
|
||||
[$page(0, null, 'a', []), 'LastMsgTime/LastMsgKey'],
|
||||
] as [$invalid, $error]) {
|
||||
$service->responses = [$invalid];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error);
|
||||
}
|
||||
|
||||
foreach ([0, 1] as $complete) {
|
||||
$service->responses = [$page($complete, 200, 'a', [$message('a', 200)])];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '游标重复');
|
||||
$service->responses = [$page($complete, 200, 'a', [$message('a', 200)])];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']), '游标重复');
|
||||
}
|
||||
$service->responses = [$page(0, 201, 'future', [])];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '时间超出');
|
||||
|
||||
$otherPatientMessage = $message('other-patient', 200);
|
||||
$otherPatientMessage['To_Account'] = 'patient_999';
|
||||
$service->responses = [$page(1, 200, 'other-patient', [$otherPatientMessage])];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '不属于当前会话');
|
||||
foreach ([
|
||||
'invalid-message',
|
||||
array_replace($message('bad', 200), ['MsgKey' => '']),
|
||||
array_replace($message('bad', 200), ['MsgTimeStamp' => '200']),
|
||||
array_replace($message('bad', 200), ['MsgBody' => null]),
|
||||
] as $invalidMessage) {
|
||||
$service->responses = [$page(1, 200, 'bad', [$invalidMessage])];
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '非法');
|
||||
}
|
||||
|
||||
$before = count($service->requests);
|
||||
foreach ([['max_time' => '200'], ['max_time' => -1], ['min_time' => 201, 'max_time' => 200], ['last_key' => []], ['seen_keys' => [null]]] as $invalidCursor) {
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $invalidCursor), '游标无效');
|
||||
}
|
||||
roamFails(static fn () => ImRoamMessagePager::nextPage($service, '', 'patient_2'), '账号无效');
|
||||
roamExpect(count($service->requests) === $before, 'Invalid requests are rejected before HTTP');
|
||||
|
||||
$service->responses = [$page(1, null, null, [])];
|
||||
$empty = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2');
|
||||
roamExpect($empty['completed'] && $empty['msgList'] === [], 'Explicit successful empty response can complete a conversation');
|
||||
|
||||
echo "IM_ROAM_MESSAGE_PAGER_TEST_OK\n";
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\TencentImService;
|
||||
use think\facade\Config;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
// Do not initialize the app or load environment/DB settings; HTTP is always replaced.
|
||||
$testApp = new think\App();
|
||||
$testApp->instance('log', new Psr\Log\NullLogger());
|
||||
Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'account-check-test-key']], 'project');
|
||||
|
||||
final class AccountCheckHttpFixture extends TencentImService
|
||||
{
|
||||
public array $responses = [];
|
||||
public array $requests = [];
|
||||
|
||||
protected function httpPost(string $url, string $data, int $timeout = 10)
|
||||
{
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
$this->requests[] = [
|
||||
'path' => parse_url($url, PHP_URL_PATH), 'query' => $query,
|
||||
'data' => json_decode($data, true, 512, JSON_THROW_ON_ERROR), 'timeout' => $timeout,
|
||||
];
|
||||
if ($this->responses === []) throw new RuntimeException('Unexpected additional HTTP request');
|
||||
$response = array_shift($this->responses);
|
||||
if ($response instanceof Throwable) throw $response;
|
||||
return is_array($response) ? json_encode($response, JSON_THROW_ON_ERROR) : $response;
|
||||
}
|
||||
|
||||
public function importAccount(string $userId, string $nick = '', string $faceUrl = '')
|
||||
{
|
||||
throw new RuntimeException('Read-only account checks must not import accounts');
|
||||
}
|
||||
|
||||
public function batchImportAccounts(array $accounts): array
|
||||
{
|
||||
throw new RuntimeException('Read-only account checks must not import accounts');
|
||||
}
|
||||
}
|
||||
|
||||
function accountCheckExpect(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
function accountCheckFails(callable $action, string $expected, int $code = 0): void
|
||||
{
|
||||
try { $action(); }
|
||||
catch (RuntimeException $exception) {
|
||||
accountCheckExpect(str_contains($exception->getMessage(), $expected), 'Expected ' . $expected . '; got ' . $exception->getMessage());
|
||||
accountCheckExpect($exception->getCode() === $code, 'Account-check errors must retain the original code');
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected account check to fail: ' . $expected);
|
||||
}
|
||||
|
||||
$item = static fn (string $account, string $status): array => [
|
||||
'UserID' => $account, 'ResultCode' => 0, 'ResultInfo' => '', 'AccountStatus' => $status,
|
||||
];
|
||||
$success = static fn (array $items): array => [
|
||||
'ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => '', 'ResultItem' => $items,
|
||||
];
|
||||
$service = new AccountCheckHttpFixture();
|
||||
accountCheckExpect($service->checkAccounts([]) === ['existing' => [], 'missing' => []] && $service->requests === [], 'Empty input makes no network request');
|
||||
|
||||
$service->responses = [$success([
|
||||
$item('doctor_3', 'Imported'), $item('patient_501', 'NotImported'), $item('doctor_1', 'Imported'),
|
||||
])];
|
||||
$result = $service->checkAccounts(['doctor_1', 'patient_501', 'doctor_3', 'doctor_1']);
|
||||
accountCheckExpect($result === ['existing' => ['doctor_1', 'doctor_3'], 'missing' => ['patient_501']], 'Only explicit NotImported is missing; results preserve requested order and deduplicate input');
|
||||
accountCheckExpect(count($service->requests) === 1 && $service->requests[0]['timeout'] === 15, 'A check performs exactly one bounded request');
|
||||
accountCheckExpect($service->requests[0]['data'] === ['CheckItem' => [
|
||||
['UserID' => 'doctor_1'], ['UserID' => 'patient_501'], ['UserID' => 'doctor_3'],
|
||||
]], 'Use the official CheckItem/UserID request shape');
|
||||
accountCheckExpect($service->requests[0]['query']['sdkappid'] === '123'
|
||||
&& $service->requests[0]['query']['identifier'] === 'administrator'
|
||||
&& $service->requests[0]['query']['contenttype'] === 'json'
|
||||
&& $service->requests[0]['query']['usersig'] !== '', 'Use configured IM app and administrator signature');
|
||||
|
||||
$hundred = array_map(static fn (int $id): string => 'doctor_' . $id, range(1, 100));
|
||||
$service->responses = [$success(array_map(static fn (string $account): array => $item($account, 'Imported'), $hundred))];
|
||||
$before = count($service->requests);
|
||||
accountCheckExpect($service->checkAccounts($hundred) === ['existing' => $hundred, 'missing' => []]
|
||||
&& count($service->requests) === $before + 1 && count($service->requests[$before]['data']['CheckItem']) === 100, 'One hundred accounts fit one official batch');
|
||||
accountCheckFails(static fn () => $service->checkAccounts(array_merge($hundred, ['doctor_101'])), '最多支持100');
|
||||
accountCheckExpect(count($service->requests) === $before + 1, 'Oversized batch is rejected rather than silently making several requests');
|
||||
|
||||
foreach ([[''], [' '], [null], [1], [false], [[]]] as $invalidAccounts) {
|
||||
$before = count($service->requests);
|
||||
accountCheckFails(static fn () => $service->checkAccounts($invalidAccounts), '非空账号字符串');
|
||||
accountCheckExpect(count($service->requests) === $before, 'Invalid account input is rejected before HTTP');
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['ActionStatus' => 'FAIL', 'ErrorCode' => 70403, 'ErrorInfo' => 'Administrator permission required'],
|
||||
['ActionStatus' => 'OK', 'ErrorCode' => 70500, 'ErrorInfo' => 'Server internal error'],
|
||||
] as $failure) {
|
||||
$service->responses = [$failure];
|
||||
$before = count($service->requests);
|
||||
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), $failure['ErrorInfo'], $failure['ErrorCode']);
|
||||
accountCheckExpect(count($service->requests) === $before + 1, 'API failure is visible without inline retries');
|
||||
}
|
||||
|
||||
$service->responses = [$success([
|
||||
$item('doctor_1', 'Imported'),
|
||||
['UserID' => 'doctor_2', 'ResultCode' => 70169, 'ResultInfo' => 'Per-account timeout', 'AccountStatus' => 'NotImported'],
|
||||
])];
|
||||
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1', 'doctor_2']), 'Per-account timeout', 70169);
|
||||
$service->responses = [$success([
|
||||
['UserID' => 'doctor_1', 'ResultCode' => 70202, 'AccountStatus' => 'NotImported'],
|
||||
])];
|
||||
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), 'ResultCode 70202', 70202);
|
||||
|
||||
foreach ([
|
||||
[false, '无响应', 0],
|
||||
['', '无响应', 0],
|
||||
[new RuntimeException('transport timed out', 28), 'transport timed out', 28],
|
||||
[new LogicException('transport refused', 7), 'transport refused', 7],
|
||||
['not-json', '响应格式非法', 0],
|
||||
['null', '响应格式非法', 0],
|
||||
[['ActionStatus' => 'OK', 'ResultItem' => []], 'ErrorCode', 0],
|
||||
[['ActionStatus' => 'OK', 'ErrorCode' => '0', 'ResultItem' => []], 'ErrorCode', 0],
|
||||
[['ErrorCode' => 0, 'ResultItem' => []], '查询失败', 0],
|
||||
[['ActionStatus' => 'OK', 'ErrorCode' => 0], 'ResultItem', 0],
|
||||
[$success(['named' => $item('doctor_1', 'Imported')]), 'ResultItem', 0],
|
||||
[$success([]), '不完整', 0],
|
||||
[$success([null]), 'UserID', 0],
|
||||
[$success([['ResultCode' => 0, 'AccountStatus' => 'Imported']]), 'UserID', 0],
|
||||
[$success([$item('unrequested', 'Imported')]), '未请求或重复', 0],
|
||||
[$success([$item('doctor_1', 'Imported'), $item('doctor_1', 'NotImported')]), '未请求或重复', 0],
|
||||
[$success([['UserID' => 'doctor_1', 'CheckResult' => 0, 'AccountStatus' => 'Imported']]), 'ResultCode', 0],
|
||||
[$success([['UserID' => 'doctor_1', 'ResultCode' => '0', 'AccountStatus' => 'Imported']]), 'ResultCode', 0],
|
||||
[$success([['UserID' => 'doctor_1', 'ResultCode' => 0]]), 'AccountStatus', 0],
|
||||
[$success([$item('doctor_1', 'Unknown')]), 'AccountStatus', 0],
|
||||
] as [$failure, $error, $code]) {
|
||||
$service->responses = [$failure];
|
||||
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), $error, $code);
|
||||
}
|
||||
|
||||
$service->responses = [$success([$item('doctor_1', 'Imported')])];
|
||||
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1', 'doctor_2']), '不完整');
|
||||
foreach ($service->requests as $request) {
|
||||
accountCheckExpect($request['path'] === '/v4/im_open_login_svc/account_check', 'All requests use the read-only account_check endpoint, never account_import');
|
||||
accountCheckExpect($request['timeout'] === 15, 'Every account check has a 15 second timeout');
|
||||
}
|
||||
|
||||
echo "TENCENT_IM_ACCOUNT_CHECK_TEST_OK\n";
|
||||
Reference in New Issue
Block a user