Compare commits

..
Author SHA1 Message Date
long 9a6146e25a docs: record restored EJ medicine count 2026-09-10 12:19:53 +08:00
long 068f3f8b06 docs: record EJ medicine sync execution 2026-09-10 12:17:06 +08:00
long 6862547a9d docs: record EJ sync production deployment 2026-09-10 12:12:06 +08:00
long 192c31fb8c docs: verify EJ medicine restore behavior 2026-09-10 11:48:56 +08:00
long eec3204408 docs: define EJ medicine restore semantics 2026-09-10 11:43:22 +08:00
long d3d66970b2 docs: finalize EJ sync branch evidence 2026-09-10 11:11:26 +08:00
long 2a0e49dbc9 docs: record EJ incremental sync verification 2026-09-10 11:11:06 +08:00
long 17e9e7b6b6 feat: add non-destructive EJ medicine sync 2026-09-10 11:09:26 +08:00
Your Name 27fbef9321 gengx 2026-09-09 15:52:46 +08:00
Your Name cb10e75ead 更新 2026-09-09 15:47:48 +08:00
Your Name bd5d5c5f08 更新 2026-09-09 14:08:11 +08:00
Your Name 083fd857e8 更新 2026-09-09 14:04:09 +08:00
Your Name 9be2e18ea6 更新 2026-09-09 12:25:20 +08:00
Your Name 691c749f34 Merge branch 'master' into UI-09-03 2026-09-09 12:19:39 +08:00
Your Name 40319eea41 更新 2026-09-09 12:18:17 +08:00
594 changed files with 18160 additions and 1454 deletions
+67
View File
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '..')
const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8')
const utils = ts.transpileModule(read('utils/appointment-type.ts'), {
compilerOptions: { module: ts.ModuleKind.CommonJS }
}).outputText
const exportsObject = {}
vm.runInNewContext(utils, { exports: exportsObject })
for (const [value, expected] of [
['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'],
[null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'],
['phone', '电话问诊'], ['other', '未知']
]) {
assert.equal(exportsObject.appointmentTypeDescription(value), expected)
}
const callers = [
'views/tcm/appointment/list.vue',
'views/tcm/appointment/list_h5.vue',
'views/patient/reception/index.vue'
]
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/)
assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/)
const form = read('views/tcm/diagnosis/appointment.vue')
assert.match(form, /<el-radio value="text">图文问诊<\/el-radio>/)
assert.match(form, /appointmentType: 'video'/)
assert.match(form, /form\.appointmentType = 'video'/)
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/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 })
assert.deepEqual(errors, [], `${filename} parses`)
const script = compileScript(descriptor, { id: filename })
const template = compileTemplate({
source: descriptor.template.content,
filename, id: filename,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [], `${filename} template compiles`)
for (const style of descriptor.styles) {
const result = await compileStyleAsync({
source: style.content, filename: path.join(root, 'src', filename),
id: filename, scoped: style.scoped, preprocessLang: style.lang
})
assert.deepEqual(result.errors, [], `${filename} styles compile`)
}
}
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 })
+23 -3
View File
@@ -313,6 +313,7 @@ export interface WecomPromotionBatchUpdatePoolResult {
success: boolean
sync_error?: string
sync_queued?: boolean
sync_status?: WecomPromotionMemberSyncStatus
member_matched?: number
member_updated?: number
error?: string
@@ -357,9 +358,28 @@ export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
export interface WecomPromotionMemberSyncResult {
pool_id: number
sync_status: WecomPromotionMemberSyncStatus
sync_error: string
sync_queued: boolean
range_userids: string[]
range_department_ids: string[]
}
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
return request.post<WecomPromotionMemberSyncResult>({
url: '/firstvisit.wecomPromotion/syncMemberRange',
params,
timeout: 120000
}, { ignoreCancelToken: true, isOpenRetry: false })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
+49 -6
View File
@@ -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)
}
// 获取企业微信聊天记录
@@ -424,6 +462,11 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
}
/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */
export function prescriptionOrderEditTime(params: { id: number; create_time: string }) {
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
+138 -9
View File
@@ -11,7 +11,10 @@
class="chat-window-header"
@mousedown="onHeaderMouseDown"
>
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<div class="chat-window-heading">
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<el-tag size="small" type="info" class="chat-appointment-type">{{ appointmentTypeLabel }}</el-tag>
</div>
<div class="chat-header-actions" @mousedown.stop>
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
<el-icon><Close /></el-icon>
@@ -52,18 +55,19 @@
<ImagePicker />
<FilePicker />
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口避免初始化登录未完成错误 -->
<AudioCallPicker v-if="isCallReady" />
<AudioCallPicker v-if="isCallReady && canAudioCall" />
<!-- 视频接通后 doBindCallRoom 后端 bindCallRoom 会调 CreateCloudRecordingRecordParams.RecordMode=2混流/合流 TrtcCloudRecordingService -->
<VideoCallPicker v-if="isCallReady" />
<VideoCallPicker v-if="isCallReady && canVideoCall" />
<!-- 群组视频通话基于 TUICallKitServer.calls多人通话入口 -->
<el-button
v-if="isCallReady"
v-if="isCallReady && canVideoCall"
size="small"
type="primary"
@click="startGroupVideoCall"
>
群视频
</el-button>
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊不支持音视频通话</span>
</div>
</template>
</MessageInput>
@@ -135,11 +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, canAppointmentVideoCall } from '@/utils/appointment-type'
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
@@ -180,6 +188,18 @@ const visible = ref(false)
const isReady = ref(false)
const error = ref('')
const patientName = ref('')
const appointmentType = ref<string | null | undefined>('video')
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('正在初始化...')
@@ -191,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'
@@ -622,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
@@ -689,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)
@@ -933,6 +1017,9 @@ onMounted(() => {
})
onUnmounted(() => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
clearLocalRecordingStartTimer()
localCallRecorder.reset()
tearDownCallRoomBinding?.()
@@ -995,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
@@ -1010,13 +1098,23 @@ watch(() => activeConversation.value, async (newConversation) => {
}
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
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
posY.value = 80
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 = ''
@@ -1029,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)
@@ -1116,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()
@@ -1157,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
@@ -1173,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
@@ -1353,6 +1461,9 @@ const onHeaderMouseUp = () => {
}
const handleClose = async () => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
unbindImHangupMessageListener()
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
if (isCallReady.value) {
@@ -1426,6 +1537,18 @@ defineExpose({ open })
justify-content: space-between;
flex-shrink: 0;
.chat-window-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.chat-appointment-type {
flex-shrink: 0;
margin-right: 8px;
}
.chat-window-title {
font-size: 16px;
font-weight: 500;
@@ -1510,6 +1633,12 @@ defineExpose({ open })
}
/* 消息工具栏样式 */
.text-consultation-hint {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.message-toolbar {
display: flex;
gap: 8px;
+19
View File
@@ -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('当前问诊已切换,请重新发起通话')
}
+13
View File
@@ -0,0 +1,13 @@
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
export function appointmentTypeDescription(value?: string | null): string {
if (value == null || value.trim() === '') return '视频问诊'
if (value === 'text') return '图文问诊'
if (value === 'video') return '视频问诊'
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
}
}
+245
View File
@@ -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
}
@@ -0,0 +1,93 @@
<template>
<el-dialog
v-model="visible"
title="修改订单创建时间"
width="min(440px, 94vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="业务订单">
<span class="break-all">{{ orderNo || `#${form.id}` }}</span>
</el-form-item>
<el-form-item label="创建时间" prop="create_time">
<el-date-picker
v-model="form.create_time"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择创建时间"
:clearable="false"
:disabled="submitting"
class="!w-full"
/>
</el-form-item>
</el-form>
<p class="text-xs text-gray-500">保存后列表和业绩统计将按新的创建时间归属日期修改记录可在订单日志中查看</p>
<template #footer>
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { prescriptionOrderEditTime } from '@/api/tcm'
import feedback from '@/utils/feedback'
const emit = defineEmits<{ (event: 'saved', id: number): void }>()
const visible = ref(false)
const submitting = ref(false)
const orderNo = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ id: 0, create_time: '' })
const rules: FormRules = {
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
}
// 列表可能返回日期字符串或历史 Unix 时间戳;保留秒,避免只打开弹窗就丢失精度。
function editableTime(value: unknown): string {
const raw = String(value ?? '').trim()
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
return raw.replace('T', ' ').padEnd(19, ':00')
}
if (!/^\d+$/.test(raw) || Number(raw) <= 0) return ''
const timestamp = Number(raw)
const date = new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
if (Number.isNaN(date.getTime())) return ''
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function open(row: { id?: unknown; order_no?: unknown; create_time?: unknown }) {
if (submitting.value || !Number(row.id)) return
form.id = Number(row.id)
form.create_time = editableTime(row.create_time)
orderNo.value = String(row.order_no || '')
visible.value = true
nextTick(() => formRef.value?.clearValidate())
}
async function submit() {
if (submitting.value || !formRef.value) return
submitting.value = true
try {
if (!(await formRef.value.validate().catch(() => false))) return
await prescriptionOrderEditTime({ id: form.id, create_time: form.create_time })
feedback.msgSuccess('订单创建时间已修改')
visible.value = false
emit('saved', form.id)
} catch {
// 请求拦截器展示错误,保留已填写的时间便于重试。
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
@@ -178,7 +178,8 @@ export function logActionText(act: string) {
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
update_amount: '修改订单金额',
edit_time: '修改创建时间',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
@@ -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,11 +187,10 @@
<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" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
@@ -293,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'
@@ -303,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
})
@@ -313,6 +323,7 @@ const queryInit = {
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
appointment_type: '',
status: '' as number | '',
channel_source: ''
}
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
@@ -3882,7 +3882,18 @@ const handleDelete = async (id: number) => {
getLists()
}
onMounted(async () => {
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
let refreshOnReturn = false
onDeactivated(() => {
refreshOnReturn = true
})
onActivated(() => {
if (!refreshOnReturn) return
refreshOnReturn = false
getLists()
})
onMounted(async () => {
await loadRoleOptions()
await loadRegionData()
await loadServicePackageOptions()
@@ -550,7 +550,13 @@
type="primary"
link
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
type="primary"
link
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -649,7 +655,12 @@
>{{ shipModeLabel(detail.ship_mode) }}</el-tag>
</div>
</template>
<template #header-actions="{ detail }">
<template #header-actions="{ detail }">
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detail)"
>修改创建时间</el-button>
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
@@ -1511,7 +1522,9 @@
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
@@ -2031,7 +2044,8 @@ import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncCompone
import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import {
TCM_ASSISTANT_ROLE_ID,
@@ -3087,7 +3101,13 @@ function canUploadPharmacyRow(row: {
}
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
await detailDrawerRef.value?.refreshIfCurrent(id)
}
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh() */
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
@@ -474,7 +474,12 @@
v-perms="['tcm.prescriptionOrder/edit']"
size="small"
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -592,8 +597,13 @@
>
撤回支付审核
</el-button>
<el-button
v-if="canShipRow(detailData)"
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detailData)"
>修改创建时间</el-button>
<el-button
v-if="canShipRow(detailData)"
v-perms="['tcm.prescriptionOrder/ship']"
type="primary"
size="small"
@@ -2566,12 +2576,13 @@
</div>
</el-drawer>
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
</div>
</template>
<script lang="ts" setup name="prescriptionOrderListH5">
<script lang="ts" setup name="prescriptionOrderListH5">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import {
Refresh,
@@ -2585,7 +2596,8 @@ import {
Wallet,
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -3513,7 +3525,15 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailData = ref<Record<string, any> | null>(null)
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
if (detailVisible.value && Number(detailData.value?.id) === id) {
await openDetail(id)
}
}
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款'
refund: '退款',
edit_time: '修改创建时间'
}
return m[act] || act
}
@@ -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'
@@ -58,31 +58,55 @@
</div>
<div class="section-heading-actions">
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
<el-button
:icon="Refresh"
:loading="syncingBatchMembers"
:disabled="!selectedPoolIds.length || memberSyncBusy || togglingMemberId > 0 || deletingPoolId > 0"
@click="syncSelectedMemberRanges"
>批量同步成员范围</el-button>
<el-button
:icon="Edit"
:disabled="!selectedPoolIds.length"
:disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openBatchConfigDialog()"
>批量修改方案</el-button>
<el-button
:icon="User"
:disabled="!selectedPoolIds.length"
:disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openAccessDialog()"
>批量设置访问操作</el-button>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
<el-button type="primary" :icon="Plus" :disabled="memberSyncBusy" @click="openPoolDialog()">新建分流方案</el-button>
</div>
</div>
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
<strong>本次保存与企微同步结果</strong>
<p v-if="syncingBatchMembers" role="status">正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案请等待完成</p>
<ul>
<li v-for="result in operationResults" :key="result.id">
<span>{{ result.name || `方案 ${result.id}` }}{{ operationResultText(result) }}</span>
<el-button
v-if="result.success && result.sync_status !== 'synced' && canSyncPool(result.id)"
type="primary"
link
:loading="syncingPoolId === result.id"
:disabled="memberSyncBusy"
@click="syncMemberRange(result.id)"
>重试同步</el-button>
</li>
</ul>
</section>
<div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar">
<div class="pool-select-all">
<el-checkbox
:model-value="allManageablePoolsSelected"
:indeterminate="someManageablePoolsSelected"
:disabled="!manageablePoolIds.length"
aria-label="全选可管理的分流方案"
:model-value="allSelectablePoolsSelected"
:indeterminate="someSelectablePoolsSelected"
:disabled="!selectablePoolIds.length || memberSyncBusy"
aria-label="全选可操作的分流方案"
@change="toggleAllPoolSelection"
>全选</el-checkbox>
<span>可选 {{ manageablePoolIds.length }} </span>
<span>可选 {{ selectablePoolIds.length }} </span>
</div>
<div
v-for="pool in overview.pools"
@@ -92,7 +116,7 @@
>
<el-checkbox
:model-value="selectedPoolIds.includes(Number(pool.id))"
:disabled="!pool.can_manage_access"
:disabled="(!pool.can_manage_access && !pool.can_operate) || memberSyncBusy"
:aria-label="`选择方案 ${pool.name}`"
@change="(checked) => togglePoolSelection(pool, checked)"
@click.stop
@@ -126,11 +150,12 @@
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || memberSyncBusy || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button>
<el-button v-if="selectedPool.can_manage_access" :icon="User" :disabled="memberSyncBusy" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Edit" :disabled="memberSyncBusy" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0 || memberSyncBusy" @click="removePool(selectedPool)">删除</el-button>
</div>
</div>
@@ -150,7 +175,16 @@
show-icon
:closable="false"
title="企业微信原生多人路由"
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
description="上线开关和可用性表示本地规则;企微范围显示上次远端确认结果。“待移出”的成员仍可能获客,只有同步完成才会从官方链接移除。"
/>
<el-alert
class="legacy-sync-alert member-sync-state"
:type="selectedSyncState.type"
show-icon
:closable="false"
:title="selectedSyncState.title"
:description="selectedSyncState.description"
/>
<div class="member-table-area">
@@ -163,10 +197,10 @@
</div>
</template>
</el-table-column>
<el-table-column label="企微范围同步" width="130" align="center">
<el-table-column label="企微范围(上次确认)" width="165" align="center">
<template #default="{ row }">
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败后台重试中</div>
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败请查看上方原因</div>
</template>
</el-table-column>
<el-table-column label="今日 / 上限" width="125" align="center">
@@ -180,12 +214,12 @@
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || memberSyncBusy" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="selectedPool.can_operate" type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
<el-button v-if="selectedPool.can_operate" type="primary" link :disabled="memberSyncBusy" @click="openMemberDialog(row)">编辑规则</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
@@ -584,6 +618,7 @@
</el-form>
</div>
<template #footer>
<span v-if="batchSyncProgress.total" class="uploading-save-tip" role="status">本地配置已保存正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案</span>
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材请稍候</span>
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
@@ -694,9 +729,10 @@ import {
wecomPromotionSaveMember,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncMemberRange,
wecomPromotionToggleMember
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import type { WecomPromotionBatchUpdatePoolResult, WecomPromotionCustomerChatStatus, WecomPromotionMemberSyncResult } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
@@ -778,6 +814,12 @@ const accessDialogVisible = ref(false)
const savingAccess = ref(false)
const batchConfigDialogVisible = ref(false)
const savingBatchConfig = ref(false)
const syncingPoolId = ref(0)
const syncingBatchMembers = ref(false)
const memberSyncBusy = computed(() => syncingBatchMembers.value || syncingPoolId.value > 0 || savingBatchConfig.value)
const batchSyncProgress = reactive({ completed: 0, total: 0 })
type MemberOperationResult = WecomPromotionBatchUpdatePoolResult & { sync_only?: boolean }
const operationResults = ref<MemberOperationResult[]>([])
const batchConfigBusy = ref(false)
const batchConfigScroll = ref<HTMLElement>()
const batchConfigError = ref('')
@@ -808,19 +850,42 @@ const customerStats = reactive({
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
const selectedSyncState = computed(() => {
const pool = selectedPool.value
const sync = pool?.dispatch_sync || {}
const remote = Array.isArray(pool?.official_link?.range_userids) ? pool.official_link.range_userids.map(String) : []
const planned = selectedMemberRules.value.filter((row: any) => eligibility(row).className === 'is-ok').map((row: any) => String(row.userid))
const names = new Map<string, string>(selectedMemberRules.value.map((row: any) => [String(row.userid), String(row.name || row.userid)]))
const remoteNames = remote.map((id: string) => names.get(id) || id).join('、') || '暂无确认记录'
const plannedNames = planned.map((id: string) => names.get(id) || id).join('、') || '暂无可用成员'
const lastSync = pool?.official_link?.last_sync_time
const detail = `当前计划:${plannedNames};上次企微确认:${remoteNames}${lastSync ? `${formatCustomerTime(lastSync)}` : ''}`
const error = String(sync.last_error || pool?.official_link?.sync_error || '')
if (Number(sync.status) === 3 || error) return { type: 'warning' as const, title: '企微成员范围尚未同步成功', description: `${detail}${error || '同步失败,请点击“同步成员范围”重试。'}` }
if (Number(sync.status) === 4) return { type: 'warning' as const, title: '企微成员范围同步受阻', description: `${detail}请检查可用成员后重试同步。` }
if ([1, 2].includes(Number(sync.status))) return { type: 'warning' as const, title: '本地规则已保存,企微成员范围同步待完成', description: `${detail}可点击“同步成员范围”立即重试。` }
const hasDepartments = (pool?.official_link?.range_department_ids || []).length > 0
const matches = remote.length > 0 && new Set(remote).size === new Set(planned).size && planned.every((id: string) => remote.includes(id)) && !hasDepartments
if (!matches) return { type: 'warning' as const, title: '当前计划与上次企微范围不一致', description: `${detail}${hasDepartments ? '企微范围仍包含部门。' : ''}请点击“同步成员范围”更新并确认。` }
return { type: 'info' as const, title: '上次确认的企微成员范围与当前计划一致', description: detail }
})
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
const manageablePoolIds = computed(() => overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
const selectablePoolIds = computed(() => overview.pools
.filter((pool: any) => pool.can_manage_access || pool.can_operate)
.map((pool: any) => Number(pool.id)))
const selectedManageablePoolCount = computed(() => {
const selected = new Set(selectedPoolIds.value)
return manageablePoolIds.value.filter((id) => selected.has(id)).length
})
const allManageablePoolsSelected = computed(() => manageablePoolIds.value.length > 0
&& selectedManageablePoolCount.value === manageablePoolIds.value.length)
const someManageablePoolsSelected = computed(() => selectedManageablePoolCount.value > 0
&& selectedManageablePoolCount.value < manageablePoolIds.value.length)
const selectedSelectablePoolCount = computed(() => selectablePoolIds.value.filter((id) => selectedPoolIds.value.includes(id)).length)
const allSelectablePoolsSelected = computed(() => selectablePoolIds.value.length > 0
&& selectedSelectablePoolCount.value === selectablePoolIds.value.length)
const someSelectablePoolsSelected = computed(() => selectedSelectablePoolCount.value > 0
&& selectedSelectablePoolCount.value < selectablePoolIds.value.length)
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
const batchMemberStatusOptions = computed<BatchMemberStatusOption[]>(() => {
const memberOptionById = new Map(overview.member_options.map((member) => [Number(member.id), member]))
@@ -918,14 +983,12 @@ const customerMemberOptions = computed(() => customerStats.member_options.length
department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '')
})))
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}`).join(','), () => {
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}:${item.can_operate ? 1 : 0}`).join(','), () => {
const ids = overview.pools.map((item: any) => Number(item.id))
const manageableIds = new Set(overview.pools
.filter((item: any) => item.can_manage_access)
.map((item: any) => Number(item.id)))
const selectableIds = new Set(selectablePoolIds.value)
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
selectedPoolIds.value = selectedPoolIds.value.filter((id) => manageableIds.has(id))
selectedPoolIds.value = selectedPoolIds.value.filter((id) => selectableIds.has(id))
}, { immediate: true })
watch(activeTab, (tab) => {
@@ -953,7 +1016,7 @@ async function loadOverview() {
}
function togglePoolSelection(pool: any, checked: unknown) {
if (!pool?.can_manage_access) return
if (memberSyncBusy.value || (!pool?.can_manage_access && !pool?.can_operate)) return
const id = Number(pool.id)
const next = new Set(selectedPoolIds.value)
checked ? next.add(id) : next.delete(id)
@@ -961,10 +1024,12 @@ function togglePoolSelection(pool: any, checked: unknown) {
}
function toggleAllPoolSelection(checked: unknown) {
selectedPoolIds.value = checked ? [...manageablePoolIds.value] : []
if (memberSyncBusy.value) return
selectedPoolIds.value = checked ? [...selectablePoolIds.value] : []
}
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
if (memberSyncBusy.value) return
const manageableIds = new Set(overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
@@ -1011,8 +1076,119 @@ function setBatchConfigError(message: string) {
ElMessage.warning(message)
}
function syncErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'string' && error) return error
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message
return fallback
}
function operationResultText(result: MemberOperationResult) {
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
const prefix = result.sync_only ? '' : '本地已保存,'
if (result.sync_status === 'synced') return `${prefix}企微成员范围已确认同步`
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
return `${prefix}企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}${result.sync_error || '请重试同步'}`
}
return `${prefix}企微成员范围尚未确认同步,请重试同步`
}
function recordOperationResult(result: MemberOperationResult) {
const index = operationResults.value.findIndex((item) => item.id === result.id)
if (index < 0) operationResults.value.push(result)
else operationResults.value[index] = result
}
function notifySavedResult(label: string, result: Partial<WecomPromotionMemberSyncResult>) {
if (result.sync_status === 'synced') {
ElMessage.success(`${label},企微成员范围已确认同步`)
} else {
const reason = result.sync_error || '企微成员范围尚未确认同步,请点击“同步成员范围”重试'
ElMessage.warning({ message: `${label}${reason}`, duration: 8000 })
}
}
function recordSavedResult(poolId: number, name: string, result: Partial<WecomPromotionMemberSyncResult>) {
recordOperationResult({ id: poolId, name, success: true, sync_status: result.sync_status, sync_error: result.sync_error, sync_queued: result.sync_queued })
}
function canSyncPool(poolId: number) {
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
return Boolean(pool?.can_operate && pool?.official_link)
}
async function requestMemberSync(poolId: number): Promise<Partial<WecomPromotionMemberSyncResult>> {
try {
return await wecomPromotionSyncMemberRange({ pool_id: poolId })
} catch (error: unknown) {
return { pool_id: poolId, sync_status: 'failed', sync_error: syncErrorMessage(error, '同步请求失败,请重试确认企微范围'), sync_queued: false }
}
}
async function syncMemberRange(poolId: number) {
if (memberSyncBusy.value || togglingMemberId.value || !canSyncPool(poolId)) return
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
syncingPoolId.value = poolId
try {
const result = await requestMemberSync(poolId)
recordOperationResult({ id: poolId, name: pool.name, success: true, sync_only: true, ...result })
await loadOverview()
notifySavedResult('成员范围同步结果', result)
} finally {
syncingPoolId.value = 0
}
}
async function syncBatchResults(results: MemberOperationResult[]) {
operationResults.value = results.map((item) => ({ ...item }))
const queued = results.filter((item) => item.success && item.sync_queued)
Object.assign(batchSyncProgress, { total: queued.length, completed: 0 })
let next = 0
// Two workers keep large batches responsive without flooding the enterprise API.
await Promise.all(Array.from({ length: Math.min(2, queued.length) }, async () => {
while (next < queued.length) {
const item = queued[next++]
const synced = await requestMemberSync(item.id)
recordOperationResult({ ...item, ...synced })
batchSyncProgress.completed++
}
}))
}
async function syncSelectedMemberRanges() {
if (memberSyncBusy.value || togglingMemberId.value || deletingPoolId.value
|| savingPool.value || savingMember.value || savingAccess.value) return
const ids = [...new Set(selectedPoolIds.value.map(Number))]
if (!ids.length) return ElMessage.warning('请先勾选需要同步的分流方案')
if (ids.length > 100) return ElMessage.warning('单次最多同步 100 个分流方案,请分批选择')
const results: MemberOperationResult[] = ids.map((id) => {
const pool = overview.pools.find((item: any) => Number(item.id) === id)
const eligible = canSyncPool(id)
return {
id, name: pool?.name || `方案 ${id}`, success: true, sync_only: true,
sync_status: eligible ? 'pending' : 'blocked', sync_queued: eligible,
sync_error: eligible ? '' : !pool?.can_operate ? '没有该方案的操作权限' : '方案尚未生成官方链接,请先保存方案'
}
})
syncingBatchMembers.value = true
try {
await syncBatchResults(results)
selectedPoolIds.value = operationResults.value.filter((item) => item.sync_status !== 'synced').map((item) => item.id)
await loadOverview()
const synced = operationResults.value.filter((item) => item.sync_status === 'synced').length
const unconfirmed = ids.length - synced
ElMessage({
type: unconfirmed ? 'warning' : 'success',
message: `企微已确认同步 ${synced} 个方案${unconfirmed ? `${unconfirmed} 个尚未确认同步,已保留勾选` : ''}。详情见方案列表上方。`,
duration: 8000
})
} finally {
syncingBatchMembers.value = false
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
}
}
async function saveBatchConfig() {
if (savingBatchConfig.value || batchConfigBusy.value) return
if (memberSyncBusy.value || batchConfigBusy.value) return
batchConfigError.value = ''
const hasAutomationChange = overview.automation_installed
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome)
@@ -1081,11 +1257,13 @@ async function saveBatchConfig() {
}
savingBatchConfig.value = true
operationResults.value = []
try {
const result = await wecomPromotionBatchUpdatePools({
pool_ids: [...batchConfigForm.pool_ids],
changes
})
operationResults.value = result.results.map((item) => ({ ...item }))
if (result.updated === 0) {
const detail = result.results
.slice(0, 2)
@@ -1093,37 +1271,32 @@ async function saveBatchConfig() {
.join('')
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
}
await syncBatchResults(result.results)
batchConfigDialogVisible.value = false
selectedPoolIds.value = []
selectedPoolIds.value = operationResults.value
.filter((item) => !item.success || item.sync_status !== 'synced')
.map((item) => item.id)
await loadOverview()
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
const memberResultText = hasMemberStatusChange
? (result.member_updated > 0
? `${result.member_updated} 条员工规则已${memberStatusText}`
: `,所选员工均已处于${memberStatusText}状态`)
? `${result.member_updated}本地员工规则已${memberStatusText}`
: `,所选员工本地均已处于${memberStatusText}状态`)
: ''
if (result.failed > 0) {
const detail = result.results
.filter((item) => !item.success)
.slice(0, 2)
.map((item) => `${item.name || `方案 ${item.id}`}${item.error || '保存失败'}`)
.join('')
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${memberResultText}${detail ? `${detail}` : ''}`)
} else if (result.sync_error_count > 0) {
ElMessage.warning(`已更新 ${result.updated} 个方案${memberResultText},其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
} else if (result.sync_queued_count > 0) {
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}${result.sync_queued_count} 个企微链接配置将在后台同步`)
} else {
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}`)
}
const synced = operationResults.value.filter((item) => item.success && item.sync_status === 'synced').length
const unconfirmed = result.updated - synced
const summary = `本地已保存 ${result.updated} 个方案${memberResultText};企微已确认同步 ${synced}${unconfirmed ? `${unconfirmed} 个尚未确认同步` : ''}${result.failed ? `${result.failed} 个本地保存失败` : ''}`
ElMessage({ type: unconfirmed || result.failed ? 'warning' : 'success', message: `${summary}。详情见方案列表上方。`, duration: 8000 })
} catch (error: any) {
setBatchConfigError(error?.message || '批量修改分流方案失败')
} finally {
savingBatchConfig.value = false
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
}
}
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
if (memberSyncBusy.value) return
const manageableIds = new Set(overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
@@ -1185,6 +1358,7 @@ function accessSummary(pool: any) {
}
function openPoolDialog(pool?: any) {
if (memberSyncBusy.value) return
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
poolFormError.value = ''
automationBusy.value = false
@@ -1213,7 +1387,7 @@ function openPoolDialog(pool?: any) {
}
async function savePool() {
if (savingPool.value || automationBusy.value) return
if (savingPool.value || automationBusy.value || memberSyncBusy.value) return
poolFormError.value = ''
const automation = cloneAutomationConfig(poolForm.automation_config)
// Disabled sections do not submit unfinished draft rows or draft attachments.
@@ -1242,11 +1416,10 @@ async function savePool() {
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
}
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
result?.sync_error
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
recordSavedResult(Number(result?.id || poolForm.id), poolForm.name, result || {})
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
notifySavedResult(poolForm.id ? '本地方案已保存' : '分流方案已创建', result || {})
} catch (error: any) {
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
@@ -1257,6 +1430,7 @@ async function savePool() {
}
async function removePool(pool: any) {
if (memberSyncBusy.value) return
try {
await ElMessageBox.confirm(
`删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`,
@@ -1279,6 +1453,7 @@ async function removePool(pool: any) {
}
function openMemberDialog(row: any) {
if (memberSyncBusy.value) return
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: []
@@ -1295,7 +1470,10 @@ function openMemberDialog(row: any) {
}
async function saveMemberRule() {
if (savingMember.value || memberSyncBusy.value) return
savingMember.value = true
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')
try {
const result: any = await wecomPromotionSaveMember({
...memberForm,
@@ -1303,10 +1481,9 @@ async function saveMemberRule() {
active_end: memberForm.active_range?.[1] || ''
})
memberDialogVisible.value = false
recordSavedResult(poolId, poolName, result || {})
await loadOverview()
result?.sync_error
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('医助分流规则已保存')
notifySavedResult('本地医助规则已保存', result || {})
} catch (error: any) {
ElMessage.error(error?.message || '医助分流规则保存失败')
} finally {
@@ -1315,13 +1492,15 @@ async function saveMemberRule() {
}
async function handleMemberToggle(row: any, value: unknown) {
if (togglingMemberId.value || memberSyncBusy.value) return
togglingMemberId.value = Number(row.id)
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')
try {
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
recordSavedResult(poolId, poolName, result || {})
await loadOverview()
result?.sync_error
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('成员规则已保存,企微多人路由范围已重新计算')
notifySavedResult('本地成员状态已保存', result || {})
} catch (error: any) {
ElMessage.error(error?.message || '成员状态更新失败')
await loadOverview()
@@ -1459,8 +1638,10 @@ function eligibility(row: any) {
function routeStatus(row: any) {
const available = eligibility(row).className === 'is-ok'
if (row.is_in_remote_range) return { label: available ? '企微路由中' : '待移出', className: available ? 'is-online' : 'is-offline' }
return { label: available ? '待同步' : '未在路由范围', className: 'is-offline' }
if (row.is_in_remote_range === true || Number(row.is_in_remote_range) === 1) {
return { label: available ? '企微范围内' : '待移出(仍在企微)', className: available ? 'is-online' : 'is-pending' }
}
return { label: available ? '待加入企微' : '未在企微范围', className: 'is-offline' }
}
function todayCount(row: any) {
@@ -1815,6 +1996,12 @@ h1, h2, h3, p { margin: 0; }
.batch-section-tip { margin: -6px 0 12px; color: #8491a2; font-size: 11px; line-height: 1.6; }.batch-section-selectors { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.batch-section-selectors :deep(.el-checkbox) { margin-right: 0; }.automation-batch-section :deep(.automation-note) { margin-top: 14px; }
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
.status-tag.is-pending { color: #a36413; background: #fff1dd; }
.member-sync-state :deep(.el-alert__description) { max-height: 84px; overflow-y: auto; overflow-wrap: anywhere; }
.member-sync-results { margin-bottom: 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; font-size: 12px; }
.member-sync-results ul { max-height: 150px; margin: 8px 0 0; padding: 0; overflow-y: auto; list-style: none; }
.member-sync-results li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; }
.member-sync-results li span { overflow-wrap: anywhere; }.member-sync-results .el-button { flex-shrink: 0; }
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid, .batch-field-grid, .batch-member-status-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { height: auto; min-height: 0; grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); scrollbar-gutter: auto; }.pool-select-all { position: static; min-width: 126px; flex: 0 0 126px; margin: 0 6px 0 0; border-right: 1px solid var(--line); border-bottom: 0; }.pool-main { overflow: visible; }.member-table-area { height: 420px; min-height: 320px; flex: none; }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
@@ -225,6 +225,7 @@ interface QueueRow {
assistant_name?: string
appointment_date?: string
appointment_time?: string
appointment_type?: string | null
gender?: number
age?: number | null
status?: number
@@ -450,12 +451,15 @@ 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
})
} catch (error: any) {
+20 -9
View File
@@ -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,8 @@ const handleChat = async (row: any) => {
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res // 传入签名数据
})
} catch (error: any) {
@@ -1031,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
+24 -11
View File
@@ -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,15 @@ 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
})
} catch (e: any) {
@@ -776,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
@@ -1183,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,10 +30,11 @@
</el-radio-group>
</el-form-item>
<!-- 预约类型 -->
<el-form-item label="预约类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<!-- 挂号类型 -->
<el-form-item label="挂号类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<el-radio value="text">图文问诊</el-radio>
</el-radio-group>
</el-form-item>
@@ -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;
+43 -19
View File
@@ -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) {
+43 -23
View File
@@ -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;
+146
View File
@@ -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], /未返回进度/)
})
+392
View File
@@ -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
}
})
@@ -0,0 +1,102 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
const source = fs.readFileSync(filename, 'utf8')
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'order-time-test' })
const template = compileTemplate({
source: descriptor.template.content,
filename,
id: 'order-time-test',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
const compiled = ts.transpileModule(script.content, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
function dialog(save = async () => ({})) {
const calls = []
const events = []
const module = { exports: {} }
const mockRequire = (name) => {
if (name === 'vue') return vue
if (name === '@/api/tcm') return {
prescriptionOrderEditTime: async (payload) => {
calls.push(payload)
return save(payload)
}
}
if (name === '@/utils/feedback') return { default: { msgSuccess() {} } }
throw new Error(`Unexpected dependency: ${name}`)
}
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
const state = module.exports.default.setup({}, {
expose() {},
emit: (...event) => events.push(event)
})
state.formRef.value = { validate: async () => true, clearValidate() {} }
return { state, calls, events }
}
test('opening and saving preserves creation time seconds and submits only the selected order', async () => {
const { state, calls, events } = dialog()
state.open({ id: 42, order_no: 'RX42', create_time: '2026-09-09 11:12:37' })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
state.form.create_time = '2026-08-31 09:08:07'
await state.submit()
assert.deepEqual(calls, [{ id: 42, create_time: '2026-08-31 09:08:07' }])
assert.deepEqual(events, [['saved', 42]])
assert.equal(state.visible.value, false)
})
test('legacy seconds, milliseconds and strings populate the same local picker time', () => {
const { state } = dialog()
const date = new Date(2026, 8, 9, 11, 12, 37)
for (const value of [date.getTime() / 1000, String(date.getTime() / 1000), date.getTime(), '2026-09-09T11:12:37']) {
state.open({ id: 1, create_time: value })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
}
state.open({ id: 1, create_time: '2026-09-09 11:12' })
assert.equal(state.form.create_time, '2026-09-09 11:12:00')
state.open({ id: 1, create_time: 0 })
assert.equal(state.form.create_time, '')
})
test('validation failures do not send requests, and failed saves retain editable input', async () => {
const { state, calls, events } = dialog(async () => { throw new Error('denied') })
state.open({ id: 9, create_time: '2026-09-09 11:12:37' })
state.formRef.value.validate = async () => { throw new Error('required') }
await state.submit()
assert.equal(calls.length, 0)
state.formRef.value.validate = async () => true
await state.submit()
assert.equal(state.visible.value, true)
assert.equal(state.submitting.value, false)
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
assert.deepEqual(events, [])
})
test('a pending request cannot submit twice or switch its target order', async () => {
let finish
const pending = new Promise((resolve) => { finish = resolve })
const { state, calls, events } = dialog(() => pending)
state.open({ id: 7, create_time: '2026-09-09 11:12:37' })
const saving = state.submit()
await state.submit()
await new Promise((resolve) => setImmediate(resolve))
state.open({ id: 8, create_time: '2026-09-08 00:00:00' })
await state.submit()
assert.equal(calls.length, 1)
assert.equal(state.form.id, 7)
finish({})
await saving
assert.deepEqual(events, [['saved', 7]])
})
@@ -0,0 +1,362 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'member-sync-test' })
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
function loadModule(source, mockRequire) {
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
return module.exports
}
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
function page(api = {}, pools = [pool()]) {
const messages = []
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
const ElMessage = (value) => emitMessage(value.type, value)
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
const instance = loadModule(script.content, (name) => {
if (name === 'vue') return { ...vue, onMounted() {} }
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
if (name === '@element-plus/icons-vue') return {}
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
if (name.endsWith('.vue')) return {}
if (name === './components/promotion-automation') return automation
throw new Error(`Unexpected dependency: ${name}`)
}).default.setup({}, { expose() {} })
Object.assign(instance.overview, { pools })
instance.selectedPoolId.value = pools[0]?.id
return { state: instance, messages }
}
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
const { state } = page()
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
})
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
await state.handleMemberToggle(member(2), false)
assert.deepEqual(calls, [{ id: 2, status: 0 }])
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
})
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
Object.assign(state.memberForm, { id: 2, active_range: [] })
await state.saveMemberRule()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /企微成员范围不一致/)
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
})
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
await state.savePool()
assert.equal(state.poolDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.equal(state.operationResults.value[0].sync_queued, true)
})
test('manual retry targets only its pool and does not import remote links', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
await state.syncMemberRange(1)
assert.deepEqual(calls, [{ pool_id: 1 }])
assert.equal(state.operationResults.value[0].sync_status, 'synced')
assert.equal(state.syncingPoolId.value, 0)
assert.equal(messages.at(-1).type, 'success')
state.overview.pools[0].can_operate = false
await state.syncMemberRange(1)
assert.equal(calls.length, 1)
})
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
const pending = []
let active = 0
let maximum = 0
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
}) })
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
const saving = state.syncBatchResults(results)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
pending[0].finish({ sync_status: 'synced', sync_error: '' })
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
pending[2].finish({ sync_status: 'pending', sync_error: '' })
await saving
assert.equal(maximum, 2)
assert.equal(state.batchSyncProgress.completed, 3)
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
})
test('batch save retains partial failures and never reports queued work as remote success', async () => {
const pools = [pool(1), pool(2), pool(3)]
const syncedIds = []
const { state, messages } = page({
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
{ id: 1, name: '方案 1', success: true, sync_queued: true },
{ id: 2, name: '方案 2', success: true, sync_queued: true },
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
] }),
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
syncedIds.push(pool_id)
if (pool_id === 2) throw '企微请求超时'
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
}
}, pools)
Object.assign(state.batchConfigApply, { member_status: true })
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
state.batchConfigDialogVisible.value = true
await state.saveBatchConfig()
assert.deepEqual(syncedIds, [1, 2])
assert.equal(state.savingBatchConfig.value, false)
assert.equal(state.batchConfigDialogVisible.value, false)
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
})
test('direct batch sync snapshots and deduplicates selection, limits concurrency, and waits for refresh', async () => {
const pools = [pool(1), pool(2), pool(3), pool(4)]
const pending = []
const unrelatedCalls = []
let active = 0
let maximum = 0
let refreshCount = 0
let finishRefresh
const api = Object.fromEntries([
'wecomPromotionBatchUpdatePools', 'wecomPromotionSavePool', 'wecomPromotionSaveMember',
'wecomPromotionBatchSetOperators', 'wecomPromotionSyncCustomers', 'wecomPromotionToggleMember'
].map((name) => [name, async (payload) => { unrelatedCalls.push({ name, payload }); return {} }]))
const { state, messages } = page({
...api,
wecomPromotionOverview: () => {
refreshCount++
return new Promise((resolve) => { finishRefresh = () => resolve({ pools }) })
},
wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: () => {
active--
resolve({ pool_id, sync_status: 'synced', sync_error: '', sync_queued: false })
} })
})
}, pools)
state.selectedPoolIds.value = [1, 1, 2, 3]
let completed = false
const syncing = state.syncSelectedMemberRanges().then(() => { completed = true })
assert.equal(state.syncingBatchMembers.value, true)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 3 })
// A changed ref must not alter the already-dispatched batch's work list.
state.selectedPoolIds.value = [4]
pending[0].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
assert.equal(refreshCount, 0)
pending[1].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(state.batchSyncProgress.completed, 2)
assert.equal(completed, false)
assert.equal(refreshCount, 0)
pending[2].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(maximum, 2)
assert.equal(refreshCount, 1)
assert.equal(state.syncingBatchMembers.value, true)
assert.equal(state.batchSyncProgress.completed, 3)
assert.equal(completed, false)
finishRefresh()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
assert.deepEqual(state.selectedPoolIds.value, [])
assert.deepEqual(state.operationResults.value.map((item) => item.id), [1, 2, 3])
assert.ok(state.operationResults.value.every((item) => item.sync_only && item.sync_status === 'synced'))
assert.deepEqual(unrelatedCalls, [])
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /已确认同步 3 个/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
for (const result of state.operationResults.value) {
assert.match(state.operationResultText(result), /已确认同步/)
assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
}
})
test('direct batch sync keeps failed, pending and blocked pools selected with precise results', async () => {
const pools = [pool(1), pool(2), pool(3), { ...pool(4), official_link: null }, { ...pool(5), can_operate: false }]
const calls = []
let refreshCount = 0
const { state, messages } = page({
wecomPromotionOverview: async () => { refreshCount++; return { pools } },
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
if (pool_id === 2) throw new Error('企微请求超时,请检查网络')
return { pool_id, sync_status: pool_id === 1 ? 'synced' : 'pending', sync_queued: pool_id === 3, sync_error: '' }
}
}, pools)
state.selectedPoolIds.value = [1, 2, 3, 4, 5]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1, 2, 3])
assert.equal(refreshCount, 1)
assert.deepEqual(state.selectedPoolIds.value, [2, 3, 4, 5])
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', 'blocked', 'blocked'])
assert.ok(state.operationResults.value.every((item) => item.sync_only))
assert.match(state.operationResults.value[1].sync_error, /企微请求超时,请检查网络/)
assert.equal(state.operationResults.value[2].sync_queued, true)
assert.match(state.operationResultText(state.operationResults.value[3]), /链接/)
assert.match(state.operationResultText(state.operationResults.value[4]), /权限|无权/)
for (const result of state.operationResults.value) assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /已确认同步 1 个/)
assert.match(messages.at(-1).message, /4 个尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
})
test('direct batch sync prevents duplicate requests and freezes member and selection changes while busy', async () => {
const pools = [pool(1), pool(2)]
const calls = []
const toggles = []
let finish
const { state } = page({
wecomPromotionSyncMemberRange: ({ pool_id }) => {
calls.push(pool_id)
return new Promise((resolve) => { finish = () => resolve({ pool_id, sync_status: 'synced' }) })
},
wecomPromotionToggleMember: async (payload) => { toggles.push(payload); return { sync_status: 'synced' } }
}, pools)
state.selectedPoolIds.value = [1]
const syncing = state.syncSelectedMemberRanges()
await state.syncSelectedMemberRanges()
await state.syncMemberRange(2)
await state.handleMemberToggle(member(2), false)
state.togglePoolSelection(pools[0], false)
state.togglePoolSelection(pools[1], true)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(false)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1])
assert.deepEqual(calls, [1])
assert.deepEqual(toggles, [])
finish()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
})
test('direct batch sync cannot begin while a single sync, member toggle or batch save is active', async () => {
const calls = []
const { state } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { sync_status: 'synced' } } })
state.selectedPoolIds.value = [1]
for (const [busy, value] of [[state.syncingPoolId, 1], [state.togglingMemberId, 2], [state.savingBatchConfig, true]]) {
busy.value = value
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
busy.value = typeof value === 'boolean' ? false : 0
}
})
test('direct batch sync rejects empty and oversized selections but accepts 100 unique pools', async () => {
const pools = Array.from({ length: 101 }, (_, index) => pool(index + 1))
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /选择|勾选/)
assert.deepEqual(calls, [])
state.selectedPoolIds.value = pools.map((item) => item.id)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /100/)
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
state.selectedPoolIds.value = [...pools.slice(0, 100).map((item) => item.id), 1]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, pools.slice(0, 100).map((item) => item.id))
assert.equal(messages.at(-1).type, 'success')
})
test('shared operators can select and sync pools while access and configuration stay manager-only', async () => {
const pools = [
{ ...pool(1), can_manage_access: false },
{ ...pool(2), can_operate: false },
{ ...pool(3), can_operate: false, can_manage_access: false },
pool(4)
]
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
assert.deepEqual(state.selectablePoolIds.value, [1, 2, 4])
state.togglePoolSelection(pools[0], true)
state.togglePoolSelection(pools[2], true)
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [1])
state.openAccessDialog()
assert.equal(state.accessDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
state.openBatchConfigDialog()
assert.equal(state.batchConfigDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1, 2, 4])
state.openAccessDialog()
assert.deepEqual(state.accessForm.pool_ids, [2, 4])
state.openBatchConfigDialog()
assert.deepEqual(state.batchConfigForm.pool_ids, [2, 4])
state.overview.pools[0].can_operate = false
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [2, 4])
})
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -3,9 +3,9 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.4.1"
__version__ = "1.4.2"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
DEBUG_MODE = False
DEBUG_MODE = True
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
+39 -19
View File
@@ -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
@@ -489,7 +489,7 @@ class DemoDoctorRepository:
return detail
raise ApiBusinessError("挂号不存在", code=0)
def list_departments(self) -> list[dict[str, Any]]:
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Return a small demo department tree."""
return [
@@ -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": "",
}
@@ -335,8 +335,8 @@ class DoctorRepository(Protocol):
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
"""Return one appointment detail from ``doctor.appointment/detail``."""
def list_departments(self) -> list[dict[str, Any]]:
"""Return the department tree used by appointment list filters."""
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Return a department tree, optionally scoped to the account's data permissions."""
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Return an editable or permission-aware readonly diagnosis detail."""
@@ -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(
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
return dict(_require_mapping(payload, "doctor.appointment/detail"))
def list_departments(self) -> list[dict[str, Any]]:
"""Load the department tree through ``dept.dept/all``."""
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Load ``dept.dept/all`` with optional server-enforced role data scope."""
payload = self.client.get("dept.dept/all")
if isinstance(payload, list):
return [dict(row) for row in payload if isinstance(row, Mapping)]
if isinstance(payload, Mapping):
rows = payload.get("lists", payload.get("data", payload.get("tree")))
if isinstance(rows, list):
return [dict(row) for row in rows if isinstance(row, Mapping)]
return []
payload = self.client.get(
"dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
)
for _depth in range(5):
if isinstance(payload, list):
return [dict(row) for row in payload if isinstance(row, Mapping)]
if isinstance(payload, Mapping):
payload = payload.get("lists", payload.get("data", payload.get("tree")))
else:
break
raise ApiProtocolError("部门数据格式异常,请重试")
def list_reception_queue(
self,
@@ -2056,7 +2059,10 @@ class RemoteDoctorRepository:
) -> PageResult[Consultation]:
"""List diagnosis records using ``tcm.diagnosis/lists``."""
request_filters = dict(filters)
request_filters = dict(filters)
department_id = request_filters.pop("department_id", None)
if department_id not in (None, ""):
request_filters.setdefault("assistant_dept_id", department_id)
start_date = str(request_filters.pop("start_date", "") or "").strip()
end_date = str(request_filters.pop("end_date", "") or "").strip()
if start_date and start_date == end_date:
@@ -2623,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(
@@ -2646,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()
@@ -2799,8 +2799,9 @@ class NotesTimeline(QWidget):
add_requested = Signal()
upload_requested = Signal(str)
delete_attachment_requested = Signal(int, str, str)
open_attachment_requested = Signal(str)
delete_attachment_requested = Signal(int, str, str)
open_attachment_requested = Signal(str)
preview_images_requested = Signal(object, int)
def __init__(self, *, editable: bool = False, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -2879,11 +2880,14 @@ class NotesTimeline(QWidget):
attachments = QWidget()
attachments_layout = FlowLayout(attachments, horizontal_spacing=8, vertical_spacing=8)
images = _pick(note, "tongue_images", "images", default=[]) or []
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
images = [images]
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
for image in images:
path = str(_pick(image, "url", "path", "file_url", default=image) or "")
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
images = [images]
image_paths = [
str(_pick(image, "url", "path", "file_url", default=image) or "")
for image in images
]
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
for image_index, path in enumerate(image_paths):
image_host = QWidget()
image_layout = QGridLayout(image_host)
image_layout.setContentsMargins(0, 0, 0, 0)
@@ -2897,10 +2901,12 @@ class NotesTimeline(QWidget):
object_name="DiagnosisTongueThumb",
parent=image_host,
)
thumb.setEnabled(bool(path))
thumb.clicked.connect(
lambda _checked=False, target=path: self.open_attachment_requested.emit(target)
)
thumb.setEnabled(bool(path))
thumb.clicked.connect(
lambda _checked=False, sources=image_paths, index=image_index: (
self.preview_images_requested.emit(sources, index)
)
)
image_layout.addWidget(thumb, 0, 0)
if self._can_delete and note_id > 0 and path:
remove = QPushButton("×")
@@ -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,
"视频二维码",
@@ -1973,6 +1989,8 @@ class DiagnosisTableHost(QFrame):
return button
def _columns_resized(self, *_args: int) -> None:
if getattr(self, "_fitting_columns", False):
return
if self.tech_blue:
self._sync_row_heights()
self._schedule_fixed_height_sync()
@@ -2001,6 +2019,7 @@ class DiagnosisTableHost(QFrame):
self._fixed_height_timer.start(0)
def _sync_fixed_height(self) -> None:
self._fit_main_columns()
# Use actual viewport geometry: scrollbar extent and QSS frame padding
# can differ from sizeHint(). The unmanaged sibling cannot raise any
# ancestor's minimum height, so style changes cannot create feedback.
@@ -2016,6 +2035,31 @@ class DiagnosisTableHost(QFrame):
self.fixed.verticalScrollBar().setValue(self.main.verticalScrollBar().value())
self._position_empty()
self._position_fixed_shadow()
def _fit_main_columns(self) -> None:
"""Fill wide viewports without shrinking clinical columns on small ones.
The right-hand view is pinned independently. A wider main viewport used
to expose unpainted space after its last fixed-width column, breaking
both the header and selected-row background after a search.
"""
widths = list(self.LEFT_WIDTHS)
extra = max(0, self.main.viewport().width() - sum(widths))
# Give longer patient, appointment and follow-up text most of the space.
for column, weight in ((2, 1), (4, 3), (6, 2), (7, 1), (9, 1)):
widths[column] += extra * weight // 8
widths[4] += max(0, self.main.viewport().width() - sum(widths))
if widths == [self.main.columnWidth(column) for column in range(10)]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.main.setColumnWidth(column, width)
finally:
self._fitting_columns = False
# Wrapping changes with the allocated width; both panes must use the
# same recalculated heights, including appointment action overlays.
self._sync_row_heights()
def _position_empty(self) -> None:
viewport = self.main.viewport()
@@ -1103,6 +1103,11 @@ class ImagePreviewDialog(QDialog):
self._invalidate_request()
super().closeEvent(event)
def done(self, result: int) -> None:
# QDialog.reject() (including Escape) bypasses closeEvent.
self._invalidate_request()
super().done(result)
def _clock(milliseconds: int) -> str:
seconds = max(0, int(milliseconds) // 1000)
@@ -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,
@@ -59,7 +60,12 @@ from ..diagnosis_drawer import (
set_tag_item,
)
from ..diagnosis_editors import DailyRecordEditorDialog
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
from ..diagnosis_media import (
ImagePreviewDialog,
RecordingPlaybackCell,
RecordingPlayerDialog,
safe_image_sources,
)
from ..infinite_list import InfiniteList
from ..widgets import (
display_text,
@@ -832,6 +838,8 @@ class DiagnosisDialog(QDialog):
self._saving = False
self._load_mode = "readonly"
self._generation = 0
self._dictionary_requested_generation = -1
self._dictionary_error_message = ""
self._save_generation = 0
self._orders_generation = 0
self._order_detail_generation = 0
@@ -846,6 +854,7 @@ class DiagnosisDialog(QDialog):
self._loading_tabs: set[str] = set()
self._daily_todo_status: int | None = None
self._recording_players: list[RecordingPlayerDialog] = []
self._image_preview: ImagePreviewDialog | None = None
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
@@ -965,6 +974,7 @@ class DiagnosisDialog(QDialog):
self._add_readonly_section("daily", "日常记录", daily)
notes = NotesTimeline(editable=False)
notes.open_attachment_requested.connect(self._open_safe_resource)
notes.preview_images_requested.connect(self._preview_note_images)
self._notes_timelines.append(notes)
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
@@ -1437,6 +1447,7 @@ class DiagnosisDialog(QDialog):
timeline.upload_requested.connect(self._upload_doctor_note_material)
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
timeline.open_attachment_requested.connect(self._open_safe_resource)
timeline.preview_images_requested.connect(self._preview_note_images)
self.drawer_notes_timeline = timeline
self._notes_timelines.append(timeline)
return self._wrap_tab("DiagnosisTabNotes", timeline)
@@ -1698,22 +1709,23 @@ class DiagnosisDialog(QDialog):
if hasattr(self, "readonly_ai_button"):
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
self.tabs.clear()
for key, label, codes in _TAB_DEFINITIONS:
if not self._tab_allowed(codes):
continue
index = self.tabs.addTab(self._tab_pages[key], label)
self.tabs.tabBar().setTabData(index, key)
target = next(
(
index
for index in range(self.tabs.count())
if self.tabs.tabBar().tabData(index) == current_key
),
0,
)
self.tabs.setCurrentIndex(target)
previous_key = self._current_tab_key()
allowed_tabs = [
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
]
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
key for key, _label in allowed_tabs
]:
blocked = self.tabs.blockSignals(True)
self.tabs.clear()
target = 0
for key, label in allowed_tabs:
index = self.tabs.addTab(self._tab_pages[key], label)
self.tabs.tabBar().setTabData(index, key)
if key == previous_key:
target = index
self.tabs.setCurrentIndex(target)
self.tabs.blockSignals(blocked)
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
for key, section in self._readonly_sections.items():
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
@@ -1743,6 +1755,9 @@ class DiagnosisDialog(QDialog):
self._sync_order_offset_actions()
for panel in self._chat_panels:
panel.sync_button.setVisible(self._can_chat_sync)
self._sync_save_button()
if self._current_tab_key() != previous_key:
self._tab_changed(self.tabs.currentIndex())
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
self.drawer_banner.show_message(text, kind, action_text)
@@ -1751,6 +1766,9 @@ class DiagnosisDialog(QDialog):
self.readonly_error.show()
def _clear_message(self) -> None:
if self._dictionary_error_message and self._authoritative_detail_loaded:
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
return
self.drawer_banner.clear()
self.readonly_error.hide()
@@ -1996,6 +2014,7 @@ class DiagnosisDialog(QDialog):
)
self._authoritative_detail_loaded = False
self._saving = False
self._dictionary_error_message = ""
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2125,14 +2144,26 @@ class DiagnosisDialog(QDialog):
),
0,
)
return {"detail": detail, "patient_id": patient_id}
def _load_dictionary_choices(self) -> dict[str, Any]:
"""Load optional editor labels without extending the authoritative-detail gate."""
dictionaries: dict[str, list[tuple[str, Any]]] = {}
dictionary_errors: list[str] = []
if callable(getattr(self.repository, "get_dictionary", None)):
dictionary_types = {
dictionary_types = sorted(
{
"diagnosis_type",
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
}
for dictionary_type in sorted(dictionary_types):
)
if callable(getattr(self.repository, "get_dictionaries", None)):
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
dictionaries = {
key: _dictionary_choices(get_value(raw, key, []), key)
for key in dictionary_types
}
elif callable(getattr(self.repository, "get_dictionary", None)):
for dictionary_type in dictionary_types:
try:
raw = invoke(
self.repository,
@@ -2143,12 +2174,41 @@ class DiagnosisDialog(QDialog):
except Exception as error:
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
return {
"detail": detail,
"patient_id": patient_id,
"dictionaries": dictionaries,
"dictionary_errors": dictionary_errors,
}
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
if self._standalone_readonly or not self._authoritative_detail_loaded:
return
generation = self._generation
if not force and self._dictionary_requested_generation == generation:
return
self._dictionary_requested_generation = generation
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
self._dictionary_error_message = ""
if clear_error:
self._clear_message()
run_async(
self._load_dictionary_choices,
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
on_error=lambda error: self._dictionary_choices_loaded(
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
),
)
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
if generation != self._generation or not self._authoritative_detail_loaded:
return
# A clinician may already have edited fields while the optional labels load.
# Update only choices; re-rendering the detail would overwrite that draft.
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
errors = get_value(result, "dictionary_errors", []) or []
if errors:
self._dictionary_requested_generation = -1
self._dictionary_error_message = "".join(str(item) for item in errors)
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
filters: dict[str, Any] = {
"context_diagnosis_id": diagnosis_id,
@@ -2170,12 +2230,12 @@ class DiagnosisDialog(QDialog):
detail = get_value(result, "detail", None)
self._detail = detail
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {})
self._authoritative_detail_loaded = True
self._show_authoritative_content(True)
self._render(detail, [], [])
self._sync_form_interactivity()
self._sync_save_button()
self._set_loading(False)
errors = get_value(result, "dictionary_errors", []) or []
if errors:
self._show_message("".join(str(item) for item in errors), "warning")
@@ -2202,7 +2262,9 @@ class DiagnosisDialog(QDialog):
if generation == self._generation:
self._set_loading(False)
def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None:
def _apply_dictionary_choices(
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
) -> None:
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
labels = {
@@ -2219,15 +2281,17 @@ class DiagnosisDialog(QDialog):
("会诊", "consultation"),
)
),
preserve=False,
preserve=preserve,
)
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
if dictionary_type not in dictionaries:
continue
editor = self._choice_fields.get(key)
choices = list(dictionaries.get(dictionary_type, []))
if not _multiple:
choices.insert(0, ("", ""))
if editor is not None:
editor.set_choices(choices, preserve=False)
editor.set_choices(choices, preserve=preserve)
# Defer until the drawer has a real width so wrapped chips get height.
QTimer.singleShot(0, self._sync_choice_field_heights)
@@ -2306,9 +2370,14 @@ class DiagnosisDialog(QDialog):
if not self._authoritative_detail_loaded:
self._retry_detail()
return
if (self._dictionary_error_message
and self.drawer_banner.label.text() == self._dictionary_error_message):
self._ensure_dictionary_loaded(force=True)
return
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._close_image_preview()
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
@@ -2627,7 +2696,10 @@ class DiagnosisDialog(QDialog):
table.set_empty_text(message)
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
return
if key == "basic":
self._ensure_dictionary_loaded(force=force)
return
if key == "daily":
source = next(
@@ -3421,6 +3493,27 @@ class DiagnosisDialog(QDialog):
self._sync_order_offset_actions()
self._mutation_error(error, diagnosis_id, generation, "offset")
def _close_image_preview(self) -> None:
preview, self._image_preview = self._image_preview, None
if preview is not None:
with suppress(RuntimeError):
preview.close()
def _image_preview_finished(self, _result: int) -> None:
if self.sender() is self._image_preview:
self._image_preview = None
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
"""Keep the diagnosis open while viewing the clicked note's image group."""
if not safe_image_sources(sources):
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
return
self._close_image_preview()
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
self._image_preview = preview
preview.finished.connect(self._image_preview_finished)
preview.open()
def _open_safe_resource(self, target: str) -> None:
url = QUrl(str(target).strip())
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
@@ -3497,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)),
),
],
),
@@ -14,7 +14,8 @@ from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import (
QComboBox,
QComboBox,
QCompleter,
QDateEdit,
QDialog,
QDialogButtonBox,
@@ -37,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,
@@ -98,7 +100,32 @@ def _as_int(value: Any, default: int = 0) -> int:
return default
def _as_bool(value: Any) -> bool:
def _department_options(rows: Any) -> list[tuple[int, str]]:
"""Flatten the server-scoped tree without deriving extra options from profiles."""
if not isinstance(rows, (list, tuple)):
raise ValueError("部门数据格式异常,请重试")
options: list[tuple[int, str]] = []
seen: set[int] = set()
def visit(nodes: Sequence[Any], parents: tuple[str, ...] = ()) -> None:
for node in nodes:
if not isinstance(node, Mapping):
continue
label = str(node.get("name") or "").strip()
department_id = _as_int(node.get("id"))
path = (*parents, label) if label else parents
if department_id > 0 and label and department_id not in seen:
options.append((department_id, " / ".join(path)))
seen.add(department_id)
children = node.get("children")
if isinstance(children, (list, tuple)):
visit(children, path)
visit(rows)
return options
def _as_bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@@ -200,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:
@@ -263,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="患者"),
@@ -930,7 +958,10 @@ class ConsultationsPage(QWidget):
self._page_size = 15
self._generation = 0
self._count_generation = 0
self._options_generation = 0
self._options_generation = 0
self._departments_generation = 0
self._departments_loaded = False
self._departments_loading = False
self._prescription_generation = 0
self._mutation_generation = 0
self._menu_generation = 0
@@ -1172,15 +1203,21 @@ class ConsultationsPage(QWidget):
self.diagnosis_confirmed_combo.setObjectName("DiagnosisConfirmationFilter")
self.diagnosis_confirmed_combo.activated.connect(self._confirmation_combo_changed)
self.confirmed_combo = self.diagnosis_confirmed_combo
self.department_combo = self._fixed_combo((("全部部门", ""),))
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
department_name = display_text(
first_value(current_user, "department_name", "department", "dept_name", default=""),
"",
)
department_id = first_value(current_user, "department_id", "dept_id", default="")
if department_name:
self.department_combo.addItem(department_name, department_id or department_name)
self.department_combo = self._fixed_combo((("全部部门", ""),))
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
self.department_combo.setEditable(True)
self.department_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.department_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.department_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)
self.department_combo.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.department_combo.lineEdit().setPlaceholderText("输入部门名称搜索")
self.department_combo.lineEdit().returnPressed.connect(self._search)
self.department_retry_button = QToolButton()
self.department_retry_button.setObjectName("DiagnosisDepartmentRetry")
self.department_retry_button.setText("重试")
self.department_retry_button.setAccessibleName("重新加载部门")
self.department_retry_button.clicked.connect(lambda: self._load_departments(force=True))
self.department_retry_button.hide()
self.unserved_sort_combo = self._fixed_combo(
(
("未服务天数默认排序", ""),
@@ -1202,9 +1239,9 @@ class ConsultationsPage(QWidget):
self.secondary_filter_flow.flow.addWidget(
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
)
self.secondary_filter_flow.flow.addWidget(
self._filter_field("部门:", self.department_combo)
)
department_field = self._filter_field("部门:", self.department_combo)
department_field.layout().addWidget(self.department_retry_button)
self.secondary_filter_flow.flow.addWidget(department_field)
self.keyword_edit = QLineEdit(secondary_filters)
self.keyword_edit.setObjectName("DiagnosisKeyword")
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
@@ -1864,7 +1901,16 @@ class ConsultationsPage(QWidget):
self._page = 1
self.refresh()
def _validate_ranges(self) -> bool:
def _validate_ranges(self) -> bool:
if (
self.department_combo.isEnabled()
and self.department_combo.currentText().strip()
and self.department_combo.currentText() != self.department_combo.itemText(
self.department_combo.currentIndex()
)
):
self.banner.show_message("请从部门搜索结果中选择部门,或清空部门筛选。", "warning")
return False
pairs = (
(
self._date_value(self.latest_appointment_start_date),
@@ -1918,7 +1964,11 @@ class ConsultationsPage(QWidget):
"keyword": self.keyword_edit.text().strip(),
"patient_name": self.patient_name_edit.text().strip(),
"doctor_name": self.doctor_edit.text().strip(),
"department_id": self._combo_value(self.department_combo),
"assistant_dept_id": (
self.department_combo.currentData() or ""
if self.department_combo.currentText().strip()
else ""
),
"has_appointment": self._combo_value(self.has_appointment_combo),
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
"diagnosis_type": self._combo_value(self.diagnosis_type_combo),
@@ -2130,7 +2180,60 @@ class ConsultationsPage(QWidget):
}
self._update_quick_buttons()
def _load_filter_options(self) -> None:
def _load_departments(self, *, force: bool = False) -> None:
if not force and (self._departments_loaded or self._departments_loading):
return
self._departments_generation += 1
generation = self._departments_generation
self._departments_loading = True
self.department_combo.setEnabled(False)
self.department_combo.setToolTip("正在加载可选部门…")
self.department_retry_button.hide()
def load() -> list[tuple[int, str]]:
# Call directly: invoke() can discard unsupported kwargs, which would
# silently remove the permission scope on an older repository.
return _department_options(self.repository.list_departments(apply_data_scope=True))
run_async(
load,
on_success=lambda options: self._apply_departments(options, generation),
on_error=lambda error: self._departments_error(error, generation),
)
def _apply_departments(self, options: list[tuple[int, str]], generation: int) -> None:
if generation != self._departments_generation:
return
current = self.department_combo.currentData()
blocked = self.department_combo.blockSignals(True)
self.department_combo.clear()
self.department_combo.addItem("全部部门" if options else "暂无可选部门", "")
for department_id, label in options:
self.department_combo.addItem(label, department_id)
self.department_combo.setCurrentIndex(max(0, self.department_combo.findData(current)))
self.department_combo.blockSignals(blocked)
self._departments_loaded = True
self._departments_loading = False
self.department_combo.setEnabled(bool(options))
self.department_combo.setToolTip(
"输入部门名称搜索;选择父级包含下级部门" if options else "当前账号暂无可选部门"
)
self.department_retry_button.setVisible(not options)
def _departments_error(self, error: Exception, generation: int) -> None:
if generation != self._departments_generation:
return
self._departments_loading = False
self._departments_loaded = False
self.department_combo.clear()
self.department_combo.addItem("部门加载失败", "")
self.department_combo.setEnabled(False)
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
self.department_combo.setToolTip(message)
self.department_retry_button.setToolTip(message)
self.department_retry_button.show()
def _load_filter_options(self) -> None:
if self._options_loaded:
return
self._options_loaded = True
@@ -2554,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",
@@ -2949,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)
@@ -3397,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)
@@ -3421,9 +3528,10 @@ class ConsultationsPage(QWidget):
self.refresh(silent=True)
self._refresh_counts()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._load_filter_options()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._load_departments()
self._load_filter_options()
if not self.poll_timer.isActive():
self.poll_timer.start()
if self.table.rowCount() == 0 and not self._loading:
+19 -11
View File
@@ -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",
@@ -8,7 +8,7 @@ from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import (
QColor,
QFont,
@@ -19,7 +19,8 @@ from PySide6.QtGui import (
QTextDocument,
QTextOption,
)
from PySide6.QtWidgets import (
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDateTimeEdit,
@@ -563,7 +564,165 @@ def _create_time_cell(value: Any, _row: Any) -> str:
return format_record_time(raw)
class DoctorMultiSelect(QWidget):
class _DoctorOptionDelegate(QStyledItemDelegate):
"""Draw an explicit checked state independent of native item indicators."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize:
return QSize(220, 36)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect.adjusted(2, 1, -2, -1)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
painter.drawRoundedRect(rect, 5, 5)
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
painter.drawRoundedRect(box, 3, 3)
if checked:
pen = QPen(QColor("#FFFFFF"), 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
x, y = int(box.x()), int(box.y())
painter.drawLine(x + 4, y + 8, x + 7, y + 11)
painter.drawLine(x + 7, y + 11, x + 12, y + 5)
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#273244"))
text_rect = rect.adjusted(38, 0, -54, 0)
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
if checked:
painter.setPen(QColor("#1769E8"))
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#ADC8F2"), 1))
painter.drawRoundedRect(rect, 5, 5)
painter.restore()
def editorEvent(self, event: Any, model: Any, option: Any, index: QModelIndex) -> bool:
# The list handles whole-row toggles, independent of the native checkbox hit area.
return False
class _DoctorOptionsList(QListWidget):
@staticmethod
def _toggle(item: QListWidgetItem | None) -> None:
if item is not None and item.flags() & Qt.ItemFlag.ItemIsEnabled:
item.setCheckState(Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked
else Qt.CheckState.Checked)
def mousePressEvent(self, event: Any) -> None:
if event.button() == Qt.MouseButton.LeftButton:
item = self.itemAt(event.position().toPoint())
self.setCurrentItem(item)
self._toggle(item)
event.accept()
return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: Any) -> None:
if event.button() == Qt.MouseButton.LeftButton:
event.accept()
return
super().mouseReleaseEvent(event)
def keyPressEvent(self, event: Any) -> None:
if event.key() == Qt.Key.Key_Space:
if not event.isAutoRepeat():
self._toggle(self.currentItem())
event.accept()
return
super().keyPressEvent(event)
class _DoctorSelectionDialog(QDialog):
def __init__(self, options: Mapping[int, str], selected: set[int], parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("DoctorSelectionDialog")
self.setWindowTitle("选择医生")
self.resize(420, 500)
self.setStyleSheet("""
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
self.search = QLineEdit(self)
self.search.setObjectName("DoctorOptionSearch")
self.search.setPlaceholderText("搜索医生姓名")
self.search.setClearButtonEnabled(True)
layout.addWidget(self.search)
summary = QHBoxLayout()
self.count_label = QLabel(self)
self.count_label.setObjectName("DoctorSelectionCount")
summary.addWidget(self.count_label, 1)
self.clear_button = QPushButton("清空选择", self)
self.clear_button.clicked.connect(self._clear)
summary.addWidget(self.clear_button)
layout.addLayout(summary)
self.listing = _DoctorOptionsList(self)
self.listing.setObjectName("DoctorOptionsList")
self.listing.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.listing.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.listing.setMouseTracking(True)
self.listing.setItemDelegate(_DoctorOptionDelegate(self.listing))
for doctor_id, name in sorted(options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setToolTip(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Checked if doctor_id in selected else Qt.CheckState.Unchecked)
self.listing.addItem(item)
layout.addWidget(self.listing, 1)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok, self)
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setObjectName("ConfirmDoctorSelection")
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
self.listing.itemChanged.connect(self._update_summary)
self.search.textChanged.connect(self._filter)
self._update_summary()
def selected_ids(self) -> set[int]:
return {_int(self.listing.item(i).data(Qt.ItemDataRole.UserRole))
for i in range(self.listing.count())
if self.listing.item(i).checkState() == Qt.CheckState.Checked}
def _update_summary(self, *_args: Any) -> None:
count = len(self.selected_ids())
self.count_label.setText(f"已选 {count} 位医生")
self.clear_button.setEnabled(count > 0)
def _filter(self, text: str) -> None:
query = text.strip().casefold()
for i in range(self.listing.count()):
item = self.listing.item(i)
item.setHidden(query not in item.text().casefold())
def _clear(self) -> None:
for i in range(self.listing.count()):
self.listing.item(i).setCheckState(Qt.CheckState.Unchecked)
class DoctorMultiSelect(QWidget):
"""Compact checkable doctor selector fed by list rows/extend data."""
def __init__(self, parent: QWidget | None = None) -> None:
@@ -591,36 +750,14 @@ class DoctorMultiSelect(QWidget):
self._options[doctor_id] = name
self._update_text()
def _choose(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("选择医生")
dialog.resize(360, 430)
layout = QVBoxLayout(dialog)
listing = QListWidget()
for doctor_id, name in sorted(self._options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if doctor_id in self._selected else Qt.CheckState.Unchecked
)
listing.addItem(item)
layout.addWidget(listing)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self._selected = {
_int(listing.item(index).data(Qt.ItemDataRole.UserRole))
for index in range(listing.count())
if listing.item(index).checkState() == Qt.CheckState.Checked
}
self._selected.discard(0)
self._update_text()
def _choose(self) -> None:
dialog = _DoctorSelectionDialog(self._options, self._selected, self)
try:
if dialog.exec() == QDialog.DialogCode.Accepted:
self._selected = dialog.selected_ids() - {0}
self._update_text()
finally:
dialog.deleteLater()
def _update_text(self) -> None:
if not self._selected:
@@ -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(
+34 -1
View File
@@ -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),
)
+26 -3
View File
@@ -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
+44 -7
View File
@@ -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()
+7 -1
View File
@@ -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": "",
+1 -1
View File
@@ -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()
+215
View File
@@ -0,0 +1,215 @@
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import consultations as module
TREE = [
{"id": 10, "name": "医助部", "children": [
{"id": "11", "name": "一组", "children": [
{"id": 12, "name": "专病组", "children": []},
]},
]},
{"id": 20, "name": "另一部门", "children": []},
]
class Client:
def __init__(self, departments: Any = TREE) -> None:
self.departments = departments
self.calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.calls.append((endpoint, dict(params or {})))
if endpoint == "dept.dept/all":
if isinstance(self.departments, Exception):
raise self.departments
return self.departments
return {"lists": [], "count": 0}
class DetailStub(QWidget):
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
# Test only this page's controls; loading a global stylesheet is unrelated.
client = Client()
result = module.ConsultationsPage(
RemoteDoctorRepository(client),
current_user={"department_id": 99, "department_name": "个人所属部门"},
)
yield result, client
result.poll_timer.stop()
result.close()
result.deleteLater()
application.processEvents()
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
on_finished: Any = None, **_kwargs: Any) -> None:
try:
value = function()
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(value)
finally:
if on_finished:
on_finished()
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
def test_repository_scoped_tree_shapes(payload: Any) -> None:
client = Client(payload)
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
with pytest.raises(ApiProtocolError):
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
client = Client()
repository = RemoteDoctorRepository(client)
repository.list_consultations(department_id=12)
assert client.calls[-1][1]["assistant_dept_id"] == 12
assert "department_id" not in client.calls[-1][1]
repository.list_consultations(department_id=12, assistant_dept_id=20)
assert client.calls[-1][1]["assistant_dept_id"] == 20
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
combo = widget.department_combo
assert combo.count() == 5
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
assert combo.findData(99) == -1
assert combo.isEditable()
combo.completer().setCompletionPrefix("专病")
assert combo.completer().completionCount() == 1
combo.setCurrentIndex(combo.findData(12))
widget._search()
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
assert calls
assert all(params["assistant_dept_id"] == 12 for params in calls)
assert all("department_id" not in params for params in calls)
assert calls[-1]["page_size"] == 15
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
widget.department_combo.setEditText("不存在的部门")
widget._search()
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
widget.department_combo.setEditText("")
widget._search()
assert widget._shared_filters()["assistant_dept_id"] == ""
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
client.departments = []
widget._load_departments()
assert widget.department_combo.count() == 1
assert widget.department_combo.currentData() == ""
assert widget.department_combo.currentText() == "暂无可选部门"
assert not widget.department_combo.isEnabled()
assert not widget.department_retry_button.isHidden()
client.departments = TREE
widget.department_retry_button.click()
assert widget.department_combo.isEnabled()
assert widget.department_combo.findData(12) > 0
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
client.departments = ApiBusinessError("无权限访问部门")
widget._load_departments()
assert not widget._departments_loaded
assert not widget._departments_loading
assert widget.department_combo.currentText() == "部门加载失败"
assert "无权限" in widget.department_combo.toolTip()
assert not widget.department_retry_button.isHidden()
client.departments = TREE
widget.department_retry_button.click()
assert widget._departments_loaded
assert widget.department_retry_button.isHidden()
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
jobs: list[dict[str, Any]] = []
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
widget._load_departments()
widget._load_departments()
assert len(jobs) == 1
widget._load_departments(force=True)
assert len(jobs) == 2
jobs[1]["on_success"]([(12, "当前部门")])
widget.department_combo.setCurrentIndex(1)
jobs[0]["on_success"]([(99, "旧部门")])
jobs[0]["on_error"](RuntimeError("旧请求失败"))
assert widget.department_combo.currentData() == 12
assert widget.department_combo.findData(99) == -1
assert widget._departments_loaded
assert widget.department_combo.isEnabled()
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
widget._load_departments(force=True)
assert widget.department_combo.currentData() == 12
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
monkeypatch.setattr(module, "run_async", immediate)
calls: list[bool] = []
class OldRepository:
def list_departments(self):
calls.append(True)
return TREE
widget.repository = OldRepository()
widget._load_departments()
assert calls == []
assert not widget._departments_loaded
assert not widget.department_retry_button.isHidden()
+240
View File
@@ -0,0 +1,240 @@
"""Critical-path request counts and late metadata safety, without global Qt styling."""
from __future__ import annotations
import os
import time
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.dialogs import diagnosis as module
class Client:
def __init__(self, delay: float = 0) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
self.delay = delay
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
self.calls.append((endpoint, params))
time.sleep(self.delay)
if endpoint == "config/dict":
return {
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
for key in params["type"].split(",")
}
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
return {
"id": params["id"], "patient_id": params["id"] + 1000,
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
"diagnosis_type": "first_visit", "chief_complaint": "Original",
}
return {"lists": [], "count": 0}
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
jobs: list[dict[str, Any]] = []
def enqueue(function: Any, **callbacks: Any) -> object:
jobs.append({"function": function, **callbacks})
return object()
monkeypatch.setattr(module, "run_async", enqueue)
return jobs
def complete(job: dict[str, Any]) -> None:
job["on_success"](job["function"]())
if job.get("on_finished"):
job["on_finished"]()
def dialog_for(client: Client) -> module.DiagnosisDialog:
return module.DiagnosisDialog(
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
)
def test_detail_unlocks_before_single_batch_and_preserves_draft(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
client = Client()
dialog = dialog_for(client)
try:
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
assert dialog.isVisible()
assert dialog.drawer_loading.isVisibleTo(dialog)
assert dialog.edit_fields["patient_name"].isReadOnly()
assert not dialog.save_button.isEnabled()
assert len(queued) == 1
complete(queued[0])
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
assert dialog._authoritative_detail_loaded
assert dialog.save_button.isEnabled()
assert not dialog.drawer_loading.isVisibleTo(dialog)
assert len(queued) == 2
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
dialog.edit_fields["appetite"].setPlainText("bitter")
complete(queued[1])
assert len(client.calls) == 2
assert client.calls[1][0] == "config/dict"
assert len(client.calls[1][1]["type"].split(",")) == 16
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
assert dialog._loaded_tabs == set()
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
dialog._ensure_tab_loaded("basic")
dialog.refresh_permissions()
assert len(queued) == 2
assert len(client.calls) == 2
finally:
dialog.close()
@pytest.mark.parametrize("close", [False, True])
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
application: QApplication, queued: list[dict[str, Any]], close: bool
) -> None:
dialog = dialog_for(Client())
try:
dialog.open_for(501, editable=True)
detail_job = queued[0]
complete(detail_job)
metadata_job = queued[1]
if close:
dialog.close()
else:
dialog.open_for(502, editable=True)
complete(queued[2])
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
before = dialog.edit_fields["patient_name"].toPlainText()
complete(detail_job)
complete(metadata_job)
assert dialog.edit_fields["patient_name"].toPlainText() == before
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
if not close:
assert before == "Patient 502"
assert dialog._patient_id == 1502
finally:
dialog.close()
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
dialog = dialog_for(Client())
try:
dialog.open_for(501, editable=True)
complete(queued[0])
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
assert dialog._authoritative_detail_loaded
assert dialog.save_button.isEnabled()
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
assert not dialog.drawer_loading.isVisibleTo(dialog)
dialog._retry_current()
assert len(queued) == 3
complete(queued[2])
finally:
dialog.close()
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
client = Client()
dialog = dialog_for(client)
try:
notes_index = next(
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
)
dialog.tabs.setCurrentIndex(notes_index)
dialog.open_for(501, editable=True)
complete(queued[0])
assert dialog._current_tab_key() == "notes"
assert dialog._dictionary_requested_generation != dialog._generation
assert len(client.calls) == 1
# The only secondary request is for the active notes tab, not all tabs.
assert len(queued) == 2
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 3
complete(queued[2])
assert client.calls[-1][0] == "config/dict"
dialog.tabs.setCurrentIndex(notes_index)
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 3
finally:
dialog.close()
@pytest.mark.parametrize("retry_from_banner", [False, True])
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
) -> None:
client = Client()
dialog = dialog_for(client)
try:
dialog.open_for(501, editable=True)
complete(queued[0])
metadata_job = queued[1]
notes_index = next(i for i in range(dialog.tabs.count())
if dialog.tabs.tabBar().tabData(i) == "notes")
dialog.tabs.setCurrentIndex(notes_index)
notes_job = queued[2]
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
complete(notes_job)
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
if retry_from_banner:
dialog.drawer_banner.action_requested.emit()
else:
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 4
complete(queued[3])
assert client.calls[-1][0] == "config/dict"
assert not dialog._dictionary_error_message
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
assert dialog._authoritative_detail_loaded
finally:
dialog.close()
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
application: QApplication,
) -> None:
client = Client(delay=0.02)
dialog = dialog_for(client)
try:
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
started = time.perf_counter()
dialog.repository.get_diagnosis_detail(501)
for key in types:
dialog.repository.get_dictionary(key)
old_time = time.perf_counter() - started
old_calls = len(client.calls)
client.calls.clear()
started = time.perf_counter()
detail = dialog._load_bundle(501, "edit")
new_time = time.perf_counter() - started
assert detail["detail"]["id"] == 501
assert len(client.calls) == 1
dialog._load_dictionary_choices()
assert len(client.calls) == 2
assert old_calls == 17
assert new_time < old_time / 4
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
finally:
dialog.close()
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
assert thumb.property("loadState") == "ready"
assert thumb._rendered_pixmap.size() == QSize(64, 64)
previews: list[tuple[list[str], int]] = []
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
thumb.click()
assert opened == [tongue_url]
assert opened == []
assert previews == [([tongue_url], 0)]
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
assert len(remove_buttons) == 2
@@ -0,0 +1,149 @@
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from test_diagnosis_drawer_visual import (
ActionRepository,
_open_dialog,
_select_tab,
)
from test_diagnosis_drawer_visual import (
application as application,
)
from test_diagnosis_drawer_visual import (
immediate_async as immediate_async,
)
from test_diagnosis_media_thumbnail_visual import (
_FakeManager,
_hold_remote_load,
_png_bytes,
)
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
def _synthetic_image() -> bytes:
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
image.fill(QColor("#F4E3D8"))
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#C37B85"))
painter.drawEllipse(440, 160, 720, 900)
painter.setPen(QColor("#67434A"))
painter.setFont(QFont("Arial", 36))
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
painter.end()
buffer = QBuffer()
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
assert image.save(buffer, "PNG")
return bytes(buffer.data())
@pytest.mark.parametrize("mode", ["edit", "readonly"])
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
) -> None:
"""Exercise the wired drawer click, not just the standalone lightbox class."""
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
external: list[str] = []
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
payload = _synthetic_image()
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
preview._invalidate_request()
preview.apply_payload(payload, preview._generation)
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
if mode == "edit":
_select_tab(dialog, "notes")
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
application.processEvents()
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
assert len(thumbs) == 8
for thumb in thumbs:
assert thumb._apply_payload(payload, thumb._generation)
original_parent = dialog.parentWidget()
original_generation = dialog._generation
for index in (0, 7, 3):
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
application.processEvents()
assert external == [], "Thumbnail click must not launch the system browser"
preview = dialog._image_preview
assert isinstance(preview, ImagePreviewDialog)
assert preview.isVisible() and preview.parentWidget() is dialog
assert dialog.isVisible() and dialog.parentWidget() is original_parent
assert dialog._generation == original_generation
assert preview.current_source == urls[index]
assert preview.sources == urls
assert not preview.canvas.pixmap().isNull()
fitted_width = preview.canvas.pixmap().width()
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
assert preview.canvas.pixmap().width() == 1600 > fitted_width
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
assert preview.current_source == urls[(index + 1) % 8]
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
assert preview.current_source == urls[index]
if mode == "edit" and index == 7:
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
output.mkdir(parents=True, exist_ok=True)
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
application.processEvents()
assert preview.grab().save(str(output / "notes-image-preview.png"))
if index == 3:
QTest.keyClick(preview, Qt.Key.Key_Escape)
else:
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
application.processEvents()
assert dialog.isVisible() and timeline.isVisible()
assert dialog._image_preview is None
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert external == []
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
preview = dialog._image_preview
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
assert external == [urls[4]]
dialog.reject()
application.processEvents()
assert dialog._image_preview is None
@pytest.mark.parametrize("close_with_escape", [False, True])
def test_preview_close_cancels_pending_response(
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
) -> None:
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
manager = _FakeManager(preview)
manager.queue(_png_bytes(80, 60))
preview._manager = manager
monkeypatch.undo()
preview.reload_current()
reply = manager.replies[-1]
generation = preview._generation
preview.show()
if close_with_escape:
QTest.keyClick(preview, Qt.Key.Key_Escape)
else:
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
assert reply.aborted
assert preview._generation > generation
reply.finished.emit()
assert preview._pixmap.isNull()
application.processEvents()
+5 -5
View File
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
"""Regressions for the blank strip between query results and pinned actions."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(app):
for _ in range(4):
app.processEvents()
QTest.qWait(15)
def rows(count):
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
for i in range(count)]
@pytest.mark.parametrize("blue", [True, False])
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
host.resize(1650, 680)
host.show()
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
host.resize(width, 680)
host.set_rows(rows(count))
settle(application)
main = host.main
viewport = main.viewport()
base_width = sum(host.LEFT_WIDTHS)
assert main.horizontalHeader().length() == max(base_width, viewport.width())
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
assert main.viewport().height() == host.fixed.viewport().height()
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
if viewport.width() >= base_width:
assert main.horizontalScrollBar().maximum() == 0
if count:
main.selectRow(1)
settle(application)
row = main.visualRect(host.model.index(1, 9))
point = QPoint(viewport.width() - 4, row.bottom() - 5)
# A real data cell, rather than the QTableView's blank background,
# must occupy the entire strip leading into the pinned actions.
assert main.indexAt(point).row() == 1
assert main.indexAt(point).column() == 9
if blue:
capture = viewport.grab()
scale = capture.devicePixelRatio()
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
else:
assert main.horizontalScrollBar().maximum() > 0
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
settle(application)
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
host.close()
host.deleteLater()
+102
View File
@@ -0,0 +1,102 @@
"""Doctor choices remain explicit and transactional across mouse/search/keyboard use."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def dialog(application):
parent = DoctorMultiSelect()
widget = _DoctorSelectionDialog({1: "医生甲", 2: "医生乙", 3: "医生丙"}, {1}, parent)
widget.show()
application.processEvents()
yield widget
widget.close()
parent.deleteLater()
application.processEvents()
def item_for(dialog, doctor_id):
return next(dialog.listing.item(i) for i in range(dialog.listing.count())
if dialog.listing.item(i).data(Qt.ItemDataRole.UserRole) == doctor_id)
def test_row_and_checkbox_toggle_once_and_keyboard(dialog, application):
item = item_for(dialog, 2)
rect = dialog.listing.visualItemRect(item)
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
assert dialog.selected_ids() == {1, 2}
assert dialog.count_label.text() == "已选 2 位医生"
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton,
pos=QPoint(rect.left() + 20, rect.center().y()))
assert dialog.selected_ids() == {1}
dialog.listing.setCurrentItem(item)
dialog.listing.setFocus()
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
assert dialog.selected_ids() == {1, 2}
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
assert dialog.selected_ids() == {1}
def test_filter_preserves_checks_and_clear_includes_hidden(dialog):
dialog.search.setText("")
assert item_for(dialog, 1).isHidden()
assert not item_for(dialog, 2).isHidden()
assert dialog.selected_ids() == {1}
dialog.clear_button.click()
assert dialog.selected_ids() == set()
assert not dialog.clear_button.isEnabled()
dialog.search.clear()
assert not item_for(dialog, 1).isHidden()
def test_checked_row_blue_and_unchecked_white(dialog, application):
application.processEvents()
image = dialog.listing.viewport().grab().toImage()
scale = image.devicePixelRatio()
colors = []
for doctor_id in (1, 2):
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
int(rect.center().y() * scale)).name())
assert colors == ["#eaf2ff", "#ffffff"]
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
selector = DoctorMultiSelect()
selector.update_options([{"id": 1, "name": "医生甲"}, {"id": 2, "name": "医生乙"}])
seen = []
def choose(accept):
modal = application.activeModalWidget()
seen.append(modal.selected_ids())
item_for(modal, 2).setCheckState(Qt.CheckState.Checked)
modal.accept() if accept else modal.reject()
QTimer.singleShot(0, lambda: choose(False))
selector._choose()
assert selector.values() == []
QTimer.singleShot(0, lambda: choose(True))
selector._choose()
assert selector.values() == [2]
assert selector.button.text() == "医生乙"
QTimer.singleShot(0, lambda: choose(False))
selector._choose()
assert seen == [set(), set(), {2}]
assert selector.values() == [2]
selector.deleteLater()
+4 -1
View File
@@ -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": "",
+5
View File
@@ -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()
+1 -1
View File
@@ -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},
)
]
+277
View File
@@ -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
+103
View File
@@ -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()
+7
View File
@@ -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": "",
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+6
View File
@@ -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 || '本次挂号不支持该通话方式'))
}
}
+7
View File
@@ -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
+93 -6
View File
@@ -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)
})
+28
View File
@@ -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 迁移。需将前后端一起上线:新页面以签名接口返回的通话权限为准,旧后端不返回确认字段时不会开放通话按钮。
+19
View File
@@ -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 或迁移、未发出外部请求。视频通话限制由主代理另行实现和验证。
+495
View File
@@ -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` 通过。
+91
View File
@@ -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.
+26
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,590 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
use InvalidArgumentException;
use RuntimeException;
use think\facade\Db;
final class EjMedicineIncrementalPushService
{
private const SOURCE_SYSTEM = 'zyt';
private const APPLY_CONFIRMATION = 'INCREMENTAL_NO_DELETE';
private const DEFAULT_RUN_ID = 'zyt-incremental';
private const STATE_ID = 1;
private const LOCK_TTL = 120;
public static function assertCommandGate(bool $apply, string $confirm, int $batchSize): void
{
self::assertBatchSize($batchSize);
if ($apply && !hash_equals(self::APPLY_CONFIRMATION, $confirm)) {
throw new InvalidArgumentException(
'执行增量写入必须提供 --confirm=' . self::APPLY_CONFIRMATION
);
}
}
/**
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
* @return array{source_count:int,candidate_count:int,batch_count:int,mapped_count:int,unmapped_count:int,preserved_inactive:int,remote_delete_count:int,local_delete_count:int}
*/
public static function plan(
int $batchSize = 100,
?callable $sourceLoader = null,
?callable $mappingLoader = null
): array {
self::assertBatchSize($batchSize);
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
$localIds = array_map('intval', array_column($items, 'source_medicine_id'));
$mappingRows = $mappingLoader === null
? self::loadMappingRows($localIds)
: $mappingLoader($localIds);
$selection = self::selectCandidates($items, $mappingRows);
return [
'source_count' => count($items),
'candidate_count' => count($selection['candidates']),
'batch_count' => (int) ceil(count($selection['candidates']) / $batchSize),
'mapped_count' => $selection['mapped_count'],
'unmapped_count' => $selection['unmapped_count'],
'preserved_inactive' => $selection['preserved_inactive'],
'remote_delete_count' => 0,
'local_delete_count' => 0,
];
}
/**
* Incrementally imports every active local medicine through EJ's idempotent
* source identity and upserts only the returned ZYT projection rows.
* Existing EJ-only medicines and unrelated local projections are untouched.
*
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
* @param null|callable(string):bool $lockAcquirer
* @param null|callable(string):bool $lockRenewer
* @param null|callable(string):void $lockReleaser
* @return array<string,int|string>
*/
public static function execute(
int $batchSize = 100,
string $runId = '',
?callable $sourceLoader = null,
?callable $importer = null,
?callable $projectionUpserter = null,
?callable $mappingLoader = null,
?callable $lockAcquirer = null,
?callable $lockRenewer = null,
?callable $lockReleaser = null
): array {
self::assertBatchSize($batchSize);
$customLockCallbacks = count(array_filter(
[$lockAcquirer, $lockRenewer, $lockReleaser],
static fn (?callable $callback): bool => $callback !== null
));
if ($customLockCallbacks !== 0 && $customLockCallbacks !== 3) {
throw new InvalidArgumentException('增量同步锁回调必须同时提供 acquire、renew 和 release');
}
$lockAcquirer ??= static fn (string $token): bool => self::acquireLock($token);
$lockRenewer ??= static fn (string $token): bool => self::renewLock($token);
$lockReleaser ??= static function (string $token): void {
self::releaseLock($token);
};
$lockToken = bin2hex(random_bytes(16));
if (!$lockAcquirer($lockToken)) {
throw new DomainException('恩济药材同步正在执行,请稍后重试');
}
try {
return self::executeLocked(
$batchSize,
$runId,
$sourceLoader,
$importer,
$projectionUpserter,
$mappingLoader,
$lockRenewer,
$lockToken
);
} finally {
$lockReleaser($lockToken);
}
}
/**
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
* @param callable(string):bool $lockRenewer
* @return array<string,int|string>
*/
private static function executeLocked(
int $batchSize,
string $runId,
?callable $sourceLoader,
?callable $importer,
?callable $projectionUpserter,
?callable $mappingLoader,
callable $lockRenewer,
string $lockToken
): array {
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
$allItems = EjMedicineBootstrapItem::fromRows($sourceRows);
$localIds = array_map('intval', array_column($allItems, 'source_medicine_id'));
$mappingRows = $mappingLoader === null
? self::loadMappingRows($localIds)
: $mappingLoader($localIds);
$selection = self::selectCandidates($allItems, $mappingRows);
$items = $selection['candidates'];
$runId = self::normalizeRunId($runId);
$batches = self::buildBatches($items, $batchSize, $runId);
if ($items !== [] && $importer === null) {
if (!EjPharmacyClient::isConfigured()) {
throw new RuntimeException('恩济药房接口未启用或配置不完整');
}
$client = new EjPharmacyClient();
$importer = static fn (array $payload): array => $client->importMedicines($payload);
}
$sourceById = [];
foreach ($items as $item) {
$sourceById[(string) $item['source_medicine_id']] = $item;
}
$seenCodes = [];
$seenVersions = [];
$projectionRows = [];
$remoteCreated = 0;
$remoteExisting = 0;
foreach ($batches as $payload) {
self::assertLockLease($lockRenewer, $lockToken);
$response = $importer($payload);
self::assertLockLease($lockRenewer, $lockToken);
$responseItems = self::validateImportResponse(
$response,
$payload,
$seenCodes,
$seenVersions
);
foreach ($responseItems as $responseItem) {
$sourceId = (string) $responseItem['source_medicine_id'];
$source = $sourceById[$sourceId] ?? null;
if (!is_array($source)) {
throw new RuntimeException("恩济增量导入返回未知 source_medicine_id{$sourceId}");
}
$action = (string) $responseItem['action'];
$medicineCode = (string) $responseItem['medicine_code'];
$expectedCode = $selection['active_mapping_codes'][$sourceId] ?? null;
if ($expectedCode !== null && !hash_equals($expectedCode, $medicineCode)) {
throw new DomainException(
"本地药材 {$sourceId} 的启用映射编码 {$expectedCode} 与恩济返回 {$medicineCode} 不一致"
);
}
$remoteCreated += $action === 'created' ? 1 : 0;
$remoteExisting += $action === 'existing' ? 1 : 0;
$projectionRows[] = [
'local_medicine_id' => $sourceId,
'medicine_code' => $medicineCode,
'name' => (string) $source['name'],
'brand' => (string) ($source['brand'] ?? ''),
'unit' => (string) $source['unit'],
'settlement_price' => (string) $source['settlement_price'],
'retail_price' => (string) $source['retail_price'],
'status' => (int) $source['status'],
'catalog_version' => (int) $responseItem['catalog_version'],
];
}
}
$projectionStats = [
'catalog_created' => 0,
'catalog_updated' => 0,
'mapping_created' => 0,
'mapping_updated' => 0,
'mapping_unchanged' => 0,
];
if ($projectionRows !== []) {
self::assertLockLease($lockRenewer, $lockToken);
$projectionStats = $projectionUpserter === null
? self::upsertProjection($projectionRows, $lockToken)
: $projectionUpserter($projectionRows);
}
return array_merge([
'run_id' => $runId,
'source_count' => count($allItems),
'candidate_count' => count($items),
'batch_count' => count($batches),
'preserved_inactive' => $selection['preserved_inactive'],
'remote_created' => $remoteCreated,
'remote_existing' => $remoteExisting,
'remote_delete_count' => 0,
'local_delete_count' => 0,
], $projectionStats);
}
/**
* @param list<array<string,mixed>> $items
* @return list<array{source_system:string,import_id:string,items:array<int,array<string,mixed>>}>
*/
public static function buildBatches(array $items, int $batchSize, string $runId): array
{
self::assertBatchSize($batchSize);
$runId = self::normalizeRunId($runId);
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
(string) ($left['source_medicine_id'] ?? ''),
(string) ($right['source_medicine_id'] ?? '')
));
$batches = [];
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
$contentJson = json_encode(
$batchItems,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$batches[] = [
'source_system' => self::SOURCE_SYSTEM,
'import_id' => sprintf(
'%s-%04d-%s',
$runId,
$index + 1,
substr(hash('sha256', $contentJson), 0, 32)
),
'items' => $batchItems,
];
}
return $batches;
}
private static function assertBatchSize(int $batchSize): void
{
if ($batchSize < 1 || $batchSize > 500) {
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
}
}
private static function normalizeRunId(string $runId): string
{
$runId = trim($runId);
if ($runId === '') {
$runId = self::DEFAULT_RUN_ID;
}
if (strlen($runId) > 25 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/D', $runId) !== 1) {
throw new InvalidArgumentException('--run-id 必须为不超过 25 位的字母、数字、点、下划线或短横线');
}
return $runId;
}
/** @return array<int,array<string,mixed>> */
private static function loadSourceRows(): array
{
return Db::name('doctor_medicine')
->field('id,name,unit,settlement_price,retail_price,status')
->where('status', 1)
->whereNull('delete_time')
->order('id', 'asc')
->select()
->toArray();
}
/** @param array<int,int> $localIds @return array<int,array<string,mixed>> */
private static function loadMappingRows(array $localIds): array
{
if ($localIds === []) {
return [];
}
return Db::name('ej_medicine_mapping')
->whereIn('local_medicine_id', $localIds)
->field('id,local_medicine_id,medicine_code,status,delete_time')
->order('local_medicine_id', 'asc')
->select()
->toArray();
}
/**
* Active mappings are replayed so EJ can recover a missing remote medicine.
* A medicine with an inactive or soft-deleted mapping is intentionally
* excluded; an operator decision must never be undone by synchronization.
*
* @param list<array<string,mixed>> $items
* @param array<int,array<string,mixed>> $mappingRows
* @return array{candidates:list<array<string,mixed>>,mapped_count:int,unmapped_count:int,preserved_inactive:int,active_mapping_codes:array<string,string>}
*/
private static function selectCandidates(array $items, array $mappingRows): array
{
$byLocalId = [];
foreach ($mappingRows as $mapping) {
$localId = (int) ($mapping['local_medicine_id'] ?? 0);
if ($localId < 1 || isset($byLocalId[$localId])) {
throw new DomainException("本地药材 {$localId} 存在重复的恩济映射记录");
}
$byLocalId[$localId] = $mapping;
}
$candidates = [];
$mappedCount = 0;
$unmappedCount = 0;
$preservedInactive = 0;
$activeMappingCodes = [];
foreach ($items as $item) {
$localId = (int) $item['source_medicine_id'];
$mapping = $byLocalId[$localId] ?? null;
if ($mapping === null) {
++$unmappedCount;
$candidates[] = $item;
continue;
}
if ((int) ($mapping['status'] ?? 0) === 1 && ($mapping['delete_time'] ?? null) === null) {
$medicineCode = trim((string) ($mapping['medicine_code'] ?? ''));
if ($medicineCode === '') {
throw new DomainException("本地药材 {$localId} 的启用恩济映射编码为空");
}
++$mappedCount;
$candidates[] = $item;
$activeMappingCodes[(string) $localId] = $medicineCode;
continue;
}
++$preservedInactive;
}
return [
'candidates' => $candidates,
'mapped_count' => $mappedCount,
'unmapped_count' => $unmappedCount,
'preserved_inactive' => $preservedInactive,
'active_mapping_codes' => $activeMappingCodes,
];
}
/**
* @param array<string,mixed> $response
* @param array<string,mixed> $payload
* @param array<string,bool> $seenCodes
* @param array<int,bool> $seenVersions
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
*/
private static function validateImportResponse(
array $response,
array $payload,
array &$seenCodes,
array &$seenVersions
): array {
$nextSeenCodes = $seenCodes;
$nextSeenVersions = $seenVersions;
$items = EjMedicineBootstrapService::validateImportResponse(
$response,
$payload,
$nextSeenCodes,
$nextSeenVersions
);
$data = $response['body']['data'] ?? null;
if (!is_array($data) || !hash_equals(self::SOURCE_SYSTEM, (string) ($data['source_system'] ?? ''))) {
throw new RuntimeException('恩济药材导入响应 source_system 不匹配');
}
$expectedPayloadHash = hash('sha256', json_encode(
self::canonicalize($payload),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
));
$actualPayloadHash = strtolower(trim((string) ($data['payload_hash'] ?? '')));
if (
preg_match('/^[a-f0-9]{64}$/D', $actualPayloadHash) !== 1
|| !hash_equals($expectedPayloadHash, $actualPayloadHash)
) {
throw new RuntimeException('恩济药材导入响应 payload_hash 不匹配');
}
$seenCodes = $nextSeenCodes;
$seenVersions = $nextSeenVersions;
return $items;
}
private static function canonicalize(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map([self::class, 'canonicalize'], $value);
}
ksort($value, SORT_STRING);
foreach ($value as $key => $child) {
$value[$key] = self::canonicalize($child);
}
return $value;
}
/** @param callable(string):bool $lockRenewer */
private static function assertLockLease(callable $lockRenewer, string $lockToken): void
{
if (!$lockRenewer($lockToken)) {
throw new DomainException('恩济药材同步锁已失效,请重试');
}
}
private static function acquireLock(string $token): bool
{
$now = time();
return Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where(function ($query) use ($now): void {
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
})
->update([
'lock_token' => $token,
'lock_expires_at' => $now + self::LOCK_TTL,
'update_time' => $now,
]) === 1;
}
private static function renewLock(string $token): bool
{
$now = time();
$query = Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where('lock_token', $token)
->where('lock_expires_at', '>=', $now);
$updated = $query->update([
'lock_expires_at' => $now + self::LOCK_TTL,
'update_time' => $now,
]);
if ($updated === 1) {
return true;
}
$state = Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where('lock_token', $token)
->find();
return is_array($state) && (int) ($state['lock_expires_at'] ?? 0) >= $now;
}
private static function releaseLock(string $token): void
{
Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where('lock_token', $token)
->update([
'lock_token' => '',
'lock_expires_at' => 0,
'update_time' => time(),
]);
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array{catalog_created:int,catalog_updated:int,mapping_created:int,mapping_updated:int,mapping_unchanged:int}
*/
private static function upsertProjection(array $rows, string $lockToken): array
{
return Db::transaction(static function () use ($rows, $lockToken): array {
$stats = [
'catalog_created' => 0,
'catalog_updated' => 0,
'mapping_created' => 0,
'mapping_updated' => 0,
'mapping_unchanged' => 0,
];
$state = Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->lock(true)
->find();
if (
!is_array($state)
|| !hash_equals($lockToken, (string) ($state['lock_token'] ?? ''))
|| (int) ($state['lock_expires_at'] ?? 0) < time()
) {
throw new DomainException('恩济药材同步锁已失效,请重试');
}
$now = time();
foreach ($rows as $row) {
$localId = (int) ($row['local_medicine_id'] ?? 0);
$medicineCode = trim((string) ($row['medicine_code'] ?? ''));
if ($localId < 1 || $medicineCode === '') {
throw new RuntimeException('恩济增量导入投影缺少本地药材 ID 或 medicine_code');
}
$conflict = Db::name('ej_medicine_mapping')
->where('medicine_code', $medicineCode)
->where('local_medicine_id', '<>', $localId)
->lock(true)
->find();
if ($conflict) {
throw new DomainException(
"恩济药材编码 {$medicineCode} 已映射到本地药材 " . (int) $conflict['local_medicine_id']
);
}
$catalogValues = [
'name' => (string) $row['name'],
'brand' => (string) ($row['brand'] ?? ''),
'unit' => (string) $row['unit'],
'settlement_price' => (string) $row['settlement_price'],
'retail_price' => (string) $row['retail_price'],
'status' => (int) $row['status'],
'catalog_version' => (int) $row['catalog_version'],
'remote_deleted' => 0,
'update_time' => $now,
];
$catalog = Db::name('ej_medicine_catalog')
->where('medicine_code', $medicineCode)
->lock(true)
->find();
if ($catalog) {
Db::name('ej_medicine_catalog')->where('id', (int) $catalog['id'])->update($catalogValues);
++$stats['catalog_updated'];
} else {
Db::name('ej_medicine_catalog')->insert($catalogValues + [
'medicine_code' => $medicineCode,
'create_time' => $now,
]);
++$stats['catalog_created'];
}
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localId)
->lock(true)
->find();
$mappingValues = [
'medicine_code' => $medicineCode,
'status' => 1,
'operator_id' => 0,
'operator_name' => 'system-incremental',
'delete_time' => null,
'update_time' => $now,
];
if (!$mapping) {
Db::name('ej_medicine_mapping')->insert($mappingValues + [
'local_medicine_id' => $localId,
'create_time' => $now,
]);
++$stats['mapping_created'];
} elseif ((int) $mapping['status'] !== 1 || ($mapping['delete_time'] ?? null) !== null) {
throw new DomainException("本地药材 {$localId} 的恩济映射已被停用,增量同步保持该状态不变");
} elseif (hash_equals((string) $mapping['medicine_code'], $medicineCode)) {
++$stats['mapping_unchanged'];
} else {
throw new DomainException(
"本地药材 {$localId} 的启用映射编码 "
. (string) $mapping['medicine_code']
. " 与恩济返回 {$medicineCode} 不一致,增量同步未改写该映射"
);
}
}
return $stats;
});
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
ZYT_ROOT=${ZYT_ROOT:-/Users/long/Work/zyt-ej-medicine-sync}
EJ_ROOT=${EJ_ROOT:-/Users/long/Work/ej}
ZYT_BASE=27fbef9321c67f962e4d73f04a52272887c04f95
EJ_BASE=a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
git -C "$ZYT_ROOT" checkout "$ZYT_BASE" -- \
server/config/console.php \
server/tests/pharmacy/route_contracts.php
rm -f \
"$ZYT_ROOT/docs/ej-pharmacy-incremental-medicine-sync.md" \
"$ZYT_ROOT/server/app/command/EjPharmacyPushMedicines.php" \
"$ZYT_ROOT/server/app/common/service/pharmacy/EjMedicineIncrementalPushService.php" \
"$ZYT_ROOT/server/tests/pharmacy/incremental_medicine_push.php"
git -C "$EJ_ROOT" checkout "$EJ_BASE" -- \
server/app/common/service/pharmacy/MedicineImportService.php \
server/tests/pharmacy/route_contracts.php \
server/tests/pharmacy/medicine_import_mysql_integration.php
echo 'ROLLBACK_OK: ZYT incremental push removed; EJ unrelated-catalog preflight restored.'
@@ -0,0 +1,170 @@
OBJECT=ZYT_TO_EJ_INCREMENTAL_MEDICINE_SYNC
RESULT=NON_DESTRUCTIVE_INCREMENTAL_PUSH_DEPLOYED_EXECUTED_AND_VERIFIED
NEXT=RETRY_THE_PREVIOUSLY_FAILED_PHARMACY_ORDER
BRANCH_ZYT=codex/ej-medicine-incremental-sync
BRANCH_EJ=codex/ej-additive-medicine-import
ZYT_COMMIT=17e9e7b6b
ZYT_PUSH=origin/codex/ej-medicine-incremental-sync
EJ_COMMIT=495b02341ac940d7f7b3dac5254fb6714b41311b
EJ_PUSH=origin/codex/ej-additive-medicine-import
CHANGED_BRANCH_FIELD=ZYT ej-pharmacy:push-medicines add-only command + EJ medicine-imports unrelated-catalog preservation
CLARIFIED_BEHAVIOR=restore soft-deleted ZYT-origin EJ medicine in place; re-enable disabled ZYT-origin medicine; append new ZYT medicine; preserve active existing and EJ-only medicines
ARTIFACTS:
MODIFIED_FILE=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/MODIFIED_FILE
DIFF_FILE=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/DIFF_FILE
VERIFICATION=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt
ROLLBACK=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh
ORIGINAL:
ZYT_BASE_COMMIT=27fbef9321c67f962e4d73f04a52272887c04f95
ZYT_CONSOLE_SHA256=c722a3445f5027edcec9bb5be0981252bb6b3c28ba6f27e87364cc6829e7be6c
ZYT_ROUTE_CONTRACTS_SHA256=d17a7d25b1903f5f4bb52740f068b493a6b7842b57adc4f503bf995e43d26d7e
EJ_BASE_COMMIT=a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
EJ_IMPORT_SERVICE_SHA256=70f2444db461be0773348814849eeec1742c622e8a807d4b50b2c1190f664b29
EJ_IMPORT_TEST_SHA256=f91eca5aeb6b493a345dfb79e30614356a83695cc0cd37fe2b8f3d58b0c80e6f
BASELINE_1:
COMMAND=cd /Users/long/Work/zyt/server && php tests/pharmacy/callback_auth_integration.php
INPUT=baseline checkout 27fbef9321c67f962e4d73f04a52272887c04f95
LITERAL_OUTPUT=zyt pharmacy callback/auth integration tests passed: 57
EXIT_STATUS=0
BASELINE_2:
COMMAND=cd /Users/long/Work/zyt/server && php tests/pharmacy/run.php
INPUT=baseline checkout 27fbef9321c67f962e4d73f04a52272887c04f95
LITERAL_OUTPUT=Fatal error: Uncaught RuntimeException: tracking correction must append a strict operation log containing old and new logistics values
EXIT_STATUS=255
BASELINE_STATUS=pre-existing unrelated tracking-log contract failure; identical after this change
BASELINE_3:
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/run.php
INPUT=baseline checkout a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
LITERAL_OUTPUT=pharmacy contract tests passed: 67; pharmacy domain contract tests passed: 41; admin pharmacy contracts passed; workflow template contracts passed: 14
EXIT_STATUS=0
MODIFIED_1:
COMMAND=php /Users/long/Work/zyt/server/tests/pharmacy/incremental_medicine_push.php
INPUT=callback fixtures covering dry-run, stable import IDs, HMAC response identity, locks, created/existing responses, inactive mapping preservation, and zero deletes
LITERAL_OUTPUT=incremental EJ medicine push tests passed: 40
EXIT_STATUS=0
MODIFIED_2:
COMMAND=php heredoc harness requiring /Users/long/Work/zyt/server/tests/pharmacy/route_contracts.php
INPUT=modified command/service registration contracts
LITERAL_OUTPUT=route contracts passed: 26
EXIT_STATUS=0
MODIFIED_3:
COMMAND=cd /Users/long/Work/zyt/server && php think ej-pharmacy:push-medicines
INPUT=default dry-run; no --apply
LITERAL_OUTPUT=dry-run source=654 candidates=654 batches=7 mapped=654 unmapped=0 preserved_inactive=0 remote_delete=0 local_delete=0
EXIT_STATUS=0
MODIFIED_RESULT=no EJ HTTP write; no ZYT projection write; no delete
MODIFIED_4:
COMMAND=cd /Users/long/Work/zyt/server && php think ej-pharmacy:push-medicines --apply
INPUT=apply requested without confirmation token
LITERAL_OUTPUT=执行增量写入必须提供 --confirm=INCREMENTAL_NO_DELETE
EXIT_STATUS=1
MODIFIED_RESULT=write gate stopped before lock, HTTP, or projection mutation
MODIFIED_5:
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/run.php
INPUT=EJ additive medicine-import service with unrelated catalog rows preserved
LITERAL_OUTPUT=pharmacy contract tests passed: 67; pharmacy domain contract tests passed: 41; admin pharmacy contracts passed; workflow template contracts passed: 14
EXIT_STATUS=0
MODIFIED_6:
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/medicine_import_mysql_integration.php
INPUT=isolated temporary database containing an unrelated EJ medicine plus a new ZYT source medicine
LITERAL_OUTPUT=medicine import MySQL integration passed: 15 (temporary_database)
EXIT_STATUS=0
MODIFIED_RESULT=unrelated EJ medicine and active existing ZYT medicine preserved byte-for-byte; soft-deleted medicine restored in place with original id/code/stock; disabled medicine re-enabled; one new medicine appended; temporary database cleaned
MODIFIED_7:
COMMAND=php -l on ZYT service, ZYT command, ZYT console config, and EJ MedicineImportService
INPUT=all changed PHP runtime files
LITERAL_OUTPUT=No syntax errors detected
EXIT_STATUS=0
ROLLBACK:
COMMAND=ZYT_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/zyt EJ_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/ej /Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh
INPUT=detached copies at ZYT 27fbef932 and EJ a7439cc with clarified diffs applied; BEFORE_ZYT=6; BEFORE_EJ=3
LITERAL_OUTPUT=ROLLBACK_OK: ZYT incremental push removed; EJ unrelated-catalog preflight restored.
EXIT_STATUS=0
ROLLBACK_RESULT=AFTER_ZYT=0; AFTER_EJ=0; both copies restored to clean base behavior/status
CURRENT_STATUS=ZYT and EJ code deployed; EJ import enabled only for merchant ZYT; production incremental apply completed; target medicine is active; zero deletes
RESTORED_BEHAVIOR=ROLLBACK removes the ZYT push command/service/docs/tests and restores EJ bootstrap-only unrelated-catalog rejection
DEPLOYMENT_1:
COMMAND=ssh zyt deployment transaction installing ZYT commit 192c31fb8c601d78ed5fc7dd635ee80372a9a760 and EJ commit 495b02341ac940d7f7b3dac5254fb6714b41311b
INPUT=HOST 39.97.232.35; ZYT /www/wwwroot/zyt; EJ /www/wwwroot/ej; targeted files only
LITERAL_OUTPUT=BACKUP=/www/deploy-backups/ej-medicine-sync-20260910-120752; ZYT_DEPLOYED=192c31fb8c601d78ed5fc7dd635ee80372a9a760; EJ_DEPLOYED=495b02341ac940d7f7b3dac5254fb6714b41311b
EXIT_STATUS=0
DEPLOYMENT_RESULT=unrelated ZYT ACME file and EJ DoctorLogic/.well-known changes preserved
DEPLOYMENT_2:
COMMAND=/etc/init.d/php-fpm-82 reload
INPUT=production PHP 8.2 service after targeted file installation
LITERAL_OUTPUT=Reload service php-fpm done
EXIT_STATUS=0
DEPLOYMENT_3:
COMMAND=sha256sum deployed runtime files and git show COMMIT:PATH
INPUT=ZYT console/command/service plus EJ MedicineImportService
LITERAL_OUTPUT=all four expected hashes equal actual hashes; match=YES
EXIT_STATUS=0
DEPLOYMENT_4:
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines
INPUT=production default dry-run; no --apply
LITERAL_OUTPUT=dry-run source=654 candidates=654 batches=7 mapped=654 unmapped=0 preserved_inactive=0 remote_delete=0 local_delete=0
EXIT_STATUS=0
DEPLOYMENT_RESULT=no EJ HTTP write; no ZYT projection write; no delete
DEPLOYMENT_5:
COMMAND=curl https://admin.zhenyangtang.com.cn/ and curl https://lyej.lyenji.com/api/openapi/v1/medicines?after=0&limit=1 without HMAC headers
INPUT=public post-deployment health verification
LITERAL_OUTPUT=admin HTTP=200 text/html; EJ HTTP=401 application/json with message Missing authentication headers
EXIT_STATUS=0
DEPLOYMENT_RESULT=public admin and EJ gateway are reachable; EJ authentication middleware is active
PRODUCTION_BACKUP=/www/deploy-backups/ej-medicine-sync-20260910-120752
PRODUCTION_ROLLBACK=/www/deploy-backups/ej-medicine-sync-20260910-120752/ROLLBACK.sh
PRODUCTION_ROLLBACK_CHECK=bash -n exit 0; ZYT_BACKUP_FILES=2; EJ_BACKUP_FILES=3; EJ env restore and PHP-FPM reload included
PRODUCTION_APPLY=SUCCESS
APPLY_1:
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines --apply --confirm=INCREMENTAL_NO_DELETE --batch-size=100 --run-id=repair-20260910
INPUT=production source=654 candidates=654; EJ import feature flag initially false
LITERAL_OUTPUT=恩济药材导入失败 HTTP 403Medicine import is disabled
EXIT_STATUS=1
CORRECTION=backed up /www/wwwroot/ej/server/.env; set PHARMACY_MEDICINE_IMPORT_ENABLED=true; retained PHARMACY_MEDICINE_IMPORT_ALLOWED_MERCHANTS=ZYT; reloaded PHP-FPM
APPLY_2:
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines --apply --confirm=INCREMENTAL_NO_DELETE --batch-size=100 --run-id=repair-20260910
INPUT=production after EJ import enablement restricted to merchant ZYT
LITERAL_OUTPUT=incremental 完成 run_id=repair-20260910 source=654 candidates=654 batches=7 remote_created=0 remote_existing=654 preserved_inactive=0 catalog_created=0 catalog_updated=654 mapping_created=0 mapping_updated=0 mapping_unchanged=654 remote_delete=0 local_delete=0
EXIT_STATUS=0
APPLY_RESULT=seven EJ import batches completed; no remote or local deletes; existing mappings retained
APPLY_3:
COMMAND=signed EJ GET catalog verification through EjPharmacyClient and EjMedicineCatalogSyncPolicy
INPUT=target medicine_code EJ954E7F38D7E3
LITERAL_OUTPUT=REMOTE_RECEIVED=670; TARGET_CODE=EJ954E7F38D7E3; TARGET_NAME=生地黄; TARGET_STATUS=1; CATALOG_VERSION=1371; VERIFY_EXIT=0
EXIT_STATUS=0
APPLY_RESULT=previously rejected target medicine is present and active; EJ catalog still contains 670 rows versus 654 ZYT source medicines, so EJ-only medicines remain present
APPLY_4:
COMMAND=tail production EJ nginx access/error logs
INPUT=/api/openapi/v1/medicine-imports after 2026-09-10 12:14:51 +0800
LITERAL_OUTPUT=seven POST requests returned HTTP 201; no new medicine import PHP/upstream errors
EXIT_STATUS=0
POST_APPLY_AGGREGATE:
COMMAND=read-only aggregate over EJ pharmacy_medicine joined to pharmacy_medicine_source for the 2026-09-10 12:14:45-12:15:00 apply window
INPUT=source_system=zyt; production SELECT only
LITERAL_OUTPUT=RESTORED_OR_REENABLED=628; ZYT_ACTIVE=654; ZYT_TOTAL=654; EJ_VISIBLE_TOTAL=668; UNCHANGED_ACTIVE=26; READ_ONLY_EXIT=0
EXIT_STATUS=0
RESULT=628 previously disabled or soft-deleted ZYT-origin medicines were restored/re-enabled; all 654 ZYT-origin medicines are active; 14 additional visible EJ medicines remain
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
--------- beginning of crash
09-09 15:44:57.979 29666 29666 F DEBUG : 3 total frames
09-09 15:44:57.979 29666 29666 F DEBUG : backtrace:
09-09 15:44:57.979 29666 29666 F DEBUG : #00 pc 0000000000013150 /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:44:57.979 29666 29666 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:44:57.979 29666 29666 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:44:58.093 29771 29771 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:44:58.093 29771 29771 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:44:58.093 29771 29771 F DEBUG : Revision: '0'
09-09 15:44:58.093 29771 29771 F DEBUG : ABI: 'x86_64'
09-09 15:44:58.093 29771 29771 F DEBUG : Timestamp: 2026-09-09 15:44:58.012800050+0800
09-09 15:44:58.093 29771 29771 F DEBUG : Process uptime: 1s
09-09 15:44:58.093 29771 29771 F DEBUG : Cmdline: dark.qgbevkxqql.ebyrhddxwd
09-09 15:44:58.093 29771 29771 F DEBUG : pid: 29666, tid: 29666, name: xqql.ebyrhddxwd >>> dark.qgbevkxqql.ebyrhddxwd <<<
09-09 15:44:58.093 29771 29771 F DEBUG : uid: 10066
09-09 15:44:58.093 29771 29771 F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00007d7c940b067c
09-09 15:44:58.093 29771 29771 F DEBUG : rax 00007d7cdf2d4070 rbx 00007d7c946020d0 rcx 0000000000000001 rdx 00007d7fdbc73ba0
09-09 15:44:58.094 29771 29771 F DEBUG : r8 00007d7c946098c0 r9 00007d7c93b8c220 r10 0000000000000000 r11 00007d7c94601f54
09-09 15:44:58.094 29771 29771 F DEBUG : r12 00007d7c940cbea0 r13 00007d7cdf2d4070 r14 d65f03c0a8c17bfd r15 00007d7c940b067c
09-09 15:44:58.094 29771 29771 F DEBUG : rdi 0000000080000009 rsi 00007d7cdf2d4420
09-09 15:44:58.094 29771 29771 F DEBUG : rbp 00007d7c94601f54 rsp 00007ffcf6298070 rip 00007d7c93b8c304
09-09 15:44:58.094 29771 29771 F DEBUG : 4 total frames
09-09 15:44:58.094 29771 29771 F DEBUG : backtrace:
09-09 15:44:58.094 29771 29771 F DEBUG : #00 pc 0000000000131304 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:44:58.094 29771 29771 F DEBUG : #01 pc 00000000001d4aeb /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:44:58.094 29771 29771 F DEBUG : #02 pc 00000000000f0c8f /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:44:58.094 29771 29771 F DEBUG : #03 pc 0000000000318520 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:44:59.838 29794 29794 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7d7c932fc67c in tid 29794 (folj.gfvrmybvrf), pid 29794 (folj.gfvrmybvrf)
09-09 15:44:59.908 29794 29794 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:44:59.908 29794 29794 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:44:59.908 29794 29794 F DEBUG : Revision: '0'
09-09 15:44:59.908 29794 29794 F DEBUG : ABI: 'x86_64'
09-09 15:44:59.908 29794 29794 F DEBUG : Timestamp: 2026-09-09 15:44:59.847870098+0800
09-09 15:44:59.908 29794 29794 F DEBUG : Process uptime: 1s
09-09 15:44:59.908 29794 29794 F DEBUG : Cmdline: dark.ufnoinfolj.gfvrmybvrf
09-09 15:44:59.908 29794 29794 F DEBUG : pid: 29794, tid: 29794, name: folj.gfvrmybvrf >>> dark.ufnoinfolj.gfvrmybvrf <<<
09-09 15:44:59.908 29794 29794 F DEBUG : uid: 10067
09-09 15:44:59.908 29794 29794 F DEBUG : signal 0 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr --------
09-09 15:44:59.908 29794 29794 F DEBUG : x0 ffffffffffffffff x1 0000000000001000 x2 0000000000000007 x3 00007d7c8e9ff950
09-09 15:44:59.908 29794 29794 F DEBUG : x4 00007d7c8e9ff634 x5 00007d7c8e9ff9b4 x6 0000000000000033 x7 203d20544e495f4b
09-09 15:44:59.908 29794 29794 F DEBUG : x8 00004000206fd000 x9 d65f03c0a8c17bfd x10 0000000000000035 x11 00000000ffffffff
09-09 15:44:59.908 29794 29794 F DEBUG : x12 00007d7c8e9ff634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:44:59.908 29794 29794 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7cdf92c000 x19 00007d7c932fc67c
09-09 15:44:59.908 29794 29794 F DEBUG : x20 00004000206fc000 x21 00007d7c8e9fffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:44:59.908 29794 29794 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8e9fffb8 x27 0000000000000000
09-09 15:44:59.908 29794 29794 F DEBUG : x28 0000000000000000 x29 00007d7c8e9ffea0 lr 00004000206d3124 sp 00007d7c8e9ffe00
09-09 15:44:59.908 29794 29794 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:44:59.908 29794 29794 F DEBUG :
09-09 15:44:59.908 29794 29794 F DEBUG : 3 total frames
09-09 15:44:59.908 29794 29794 F DEBUG : backtrace:
09-09 15:44:59.908 29794 29794 F DEBUG : #00 pc 0000000000013150 /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:44:59.908 29794 29794 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:44:59.908 29794 29794 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:45:00.013 29894 29894 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:45:00.013 29894 29894 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:45:00.013 29894 29894 F DEBUG : Revision: '0'
09-09 15:45:00.013 29894 29894 F DEBUG : ABI: 'x86_64'
09-09 15:45:00.013 29894 29894 F DEBUG : Timestamp: 2026-09-09 15:44:59.945542077+0800
09-09 15:45:00.013 29894 29894 F DEBUG : Process uptime: 1s
09-09 15:45:00.013 29894 29894 F DEBUG : Cmdline: dark.ufnoinfolj.gfvrmybvrf
09-09 15:45:00.013 29894 29894 F DEBUG : pid: 29794, tid: 29794, name: folj.gfvrmybvrf >>> dark.ufnoinfolj.gfvrmybvrf <<<
09-09 15:45:00.013 29894 29894 F DEBUG : uid: 10067
09-09 15:45:00.013 29894 29894 F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00007d7c932fc67c
09-09 15:45:00.013 29894 29894 F DEBUG : rax 00007d7cdf8d4070 rbx 00007d7c9384e0d0 rcx 0000000000000001 rdx 00007d7fdbc73ba0
09-09 15:45:00.013 29894 29894 F DEBUG : r8 00007d7c938558c0 r9 00007d7c92dd8220 r10 0000000000000000 r11 00007d7c9384df54
09-09 15:45:00.013 29894 29894 F DEBUG : r12 00007d7c93317ea0 r13 00007d7cdf8d4070 r14 d65f03c0a8c17bfd r15 00007d7c932fc67c
09-09 15:45:00.013 29894 29894 F DEBUG : rdi 0000000080000009 rsi 00007d7cdf8d4420
09-09 15:45:00.013 29894 29894 F DEBUG : rbp 00007d7c9384df54 rsp 00007ffcf6298070 rip 00007d7c92dd8304
09-09 15:45:00.013 29894 29894 F DEBUG : 4 total frames
09-09 15:45:00.013 29894 29894 F DEBUG : backtrace:
09-09 15:45:00.013 29894 29894 F DEBUG : #00 pc 0000000000131304 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:45:00.013 29894 29894 F DEBUG : #01 pc 00000000001d4aeb /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:45:00.013 29894 29894 F DEBUG : #02 pc 00000000000f0c8f /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:45:00.013 29894 29894 F DEBUG : #03 pc 0000000000318520 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.506 30459 30459 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7d7c9286167c in tid 30459 (xqql.ebyrhddxwd), pid 30459 (xqql.ebyrhddxwd)
09-09 15:48:22.598 30459 30459 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:48:22.598 30459 30459 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:48:22.598 30459 30459 F DEBUG : Revision: '0'
09-09 15:48:22.598 30459 30459 F DEBUG : ABI: 'x86_64'
09-09 15:48:22.598 30459 30459 F DEBUG : Timestamp: 2026-09-09 15:48:22.517224610+0800
09-09 15:48:22.598 30459 30459 F DEBUG : Process uptime: 2s
09-09 15:48:22.598 30459 30459 F DEBUG : Cmdline: dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.598 30459 30459 F DEBUG : pid: 30459, tid: 30459, name: xqql.ebyrhddxwd >>> dark.qgbevkxqql.ebyrhddxwd <<<
09-09 15:48:22.598 30459 30459 F DEBUG : uid: 10066
09-09 15:48:22.598 30459 30459 F DEBUG : signal 0 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr --------
09-09 15:48:22.598 30459 30459 F DEBUG : x0 ffffffffffffffff x1 0000000000001000 x2 0000000000000007 x3 00007d7c8f83f950
09-09 15:48:22.598 30459 30459 F DEBUG : x4 00007d7c8f83f634 x5 00007d7c8f83f9b4 x6 0000000000000033 x7 203d20544e495f4b
09-09 15:48:22.598 30459 30459 F DEBUG : x8 00004000206fd000 x9 d65f03c0a8c17bfd x10 0000000000000035 x11 00000000ffffffff
09-09 15:48:22.598 30459 30459 F DEBUG : x12 00007d7c8f83f634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7c8ff2c000 x19 00007d7c9286167c
09-09 15:48:22.598 30459 30459 F DEBUG : x20 00004000206fc000 x21 00007d7c8f83ffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:48:22.598 30459 30459 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8f83ffb8 x27 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x28 0000000000000000 x29 00007d7c8f83fea0 lr 00004000206d3124 sp 00007d7c8f83fe00
09-09 15:48:22.598 30459 30459 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG :
09-09 15:48:22.598 30459 30459 F DEBUG : 3 total frames
09-09 15:48:22.598 30459 30459 F DEBUG : backtrace:
09-09 15:48:22.598 30459 30459 F DEBUG : #00 pc 0000000000013150 /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:48:22.730 30560 30560 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:48:22.730 30560 30560 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:48:22.730 30560 30560 F DEBUG : Revision: '0'
09-09 15:48:22.730 30560 30560 F DEBUG : ABI: 'x86_64'
09-09 15:48:22.730 30560 30560 F DEBUG : Timestamp: 2026-09-09 15:48:22.639722954+0800
09-09 15:48:22.730 30560 30560 F DEBUG : Process uptime: 2s
09-09 15:48:22.730 30560 30560 F DEBUG : Cmdline: dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.730 30560 30560 F DEBUG : pid: 30459, tid: 30459, name: xqql.ebyrhddxwd >>> dark.qgbevkxqql.ebyrhddxwd <<<
09-09 15:48:22.730 30560 30560 F DEBUG : uid: 10066
09-09 15:48:22.730 30560 30560 F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00007d7c9286167c
09-09 15:48:22.730 30560 30560 F DEBUG : rax 00007d7c8fed4070 rbx 00007d7c92db30d0 rcx 0000000000000001 rdx 00007d7fdbc73ba0
09-09 15:48:22.730 30560 30560 F DEBUG : r8 00007d7c92dba8c0 r9 00007d7c9233d220 r10 0000000000000000 r11 00007d7c92db2f54
09-09 15:48:22.730 30560 30560 F DEBUG : r12 00007d7c9287cea0 r13 00007d7c8fed4070 r14 d65f03c0a8c17bfd r15 00007d7c9286167c
09-09 15:48:22.730 30560 30560 F DEBUG : rdi 0000000080000009 rsi 00007d7c8fed4420
09-09 15:48:22.730 30560 30560 F DEBUG : rbp 00007d7c92db2f54 rsp 00007ffcf6298070 rip 00007d7c9233d304
09-09 15:48:22.730 30560 30560 F DEBUG : 4 total frames
09-09 15:48:22.730 30560 30560 F DEBUG : backtrace:
09-09 15:48:22.730 30560 30560 F DEBUG : #00 pc 0000000000131304 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #01 pc 00000000001d4aeb /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #02 pc 00000000000f0c8f /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #03 pc 0000000000318520 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
@@ -0,0 +1,81 @@
--------- beginning of crash
09-09 15:48:22.598 30459 30459 F DEBUG : x12 00007d7c8f83f634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7c8ff2c000 x19 00007d7c9286167c
09-09 15:48:22.598 30459 30459 F DEBUG : x20 00004000206fc000 x21 00007d7c8f83ffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:48:22.598 30459 30459 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8f83ffb8 x27 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x28 0000000000000000 x29 00007d7c8f83fea0 lr 00004000206d3124 sp 00007d7c8f83fe00
09-09 15:48:22.598 30459 30459 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG :
09-09 15:48:22.598 30459 30459 F DEBUG : 3 total frames
09-09 15:48:22.598 30459 30459 F DEBUG : backtrace:
09-09 15:48:22.598 30459 30459 F DEBUG : #00 pc 0000000000013150 /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:48:22.730 30560 30560 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:48:22.730 30560 30560 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:48:22.730 30560 30560 F DEBUG : Revision: '0'
09-09 15:48:22.730 30560 30560 F DEBUG : ABI: 'x86_64'
09-09 15:48:22.730 30560 30560 F DEBUG : Timestamp: 2026-09-09 15:48:22.639722954+0800
09-09 15:48:22.730 30560 30560 F DEBUG : Process uptime: 2s
09-09 15:48:22.730 30560 30560 F DEBUG : Cmdline: dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.730 30560 30560 F DEBUG : pid: 30459, tid: 30459, name: xqql.ebyrhddxwd >>> dark.qgbevkxqql.ebyrhddxwd <<<
09-09 15:48:22.730 30560 30560 F DEBUG : uid: 10066
09-09 15:48:22.730 30560 30560 F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00007d7c9286167c
09-09 15:48:22.730 30560 30560 F DEBUG : rax 00007d7c8fed4070 rbx 00007d7c92db30d0 rcx 0000000000000001 rdx 00007d7fdbc73ba0
09-09 15:48:22.730 30560 30560 F DEBUG : r8 00007d7c92dba8c0 r9 00007d7c9233d220 r10 0000000000000000 r11 00007d7c92db2f54
09-09 15:48:22.730 30560 30560 F DEBUG : r12 00007d7c9287cea0 r13 00007d7c8fed4070 r14 d65f03c0a8c17bfd r15 00007d7c9286167c
09-09 15:48:22.730 30560 30560 F DEBUG : rdi 0000000080000009 rsi 00007d7c8fed4420
09-09 15:48:22.730 30560 30560 F DEBUG : rbp 00007d7c92db2f54 rsp 00007ffcf6298070 rip 00007d7c9233d304
09-09 15:48:22.730 30560 30560 F DEBUG : 4 total frames
09-09 15:48:22.730 30560 30560 F DEBUG : backtrace:
09-09 15:48:22.730 30560 30560 F DEBUG : #00 pc 0000000000131304 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #01 pc 00000000001d4aeb /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #02 pc 00000000000f0c8f /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:48:22.730 30560 30560 F DEBUG : #03 pc 0000000000318520 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:49:41.313 30638 30638 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7d7c92f0567c in tid 30638 (folj.gfvrmybvrf), pid 30638 (folj.gfvrmybvrf)
09-09 15:49:41.393 30638 30638 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:49:41.393 30638 30638 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:49:41.393 30638 30638 F DEBUG : Revision: '0'
09-09 15:49:41.393 30638 30638 F DEBUG : ABI: 'x86_64'
09-09 15:49:41.394 30638 30638 F DEBUG : Timestamp: 2026-09-09 15:49:41.321662396+0800
09-09 15:49:41.394 30638 30638 F DEBUG : Process uptime: 1s
09-09 15:49:41.394 30638 30638 F DEBUG : Cmdline: dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:41.394 30638 30638 F DEBUG : pid: 30638, tid: 30638, name: folj.gfvrmybvrf >>> dark.ufnoinfolj.gfvrmybvrf <<<
09-09 15:49:41.394 30638 30638 F DEBUG : uid: 10067
09-09 15:49:41.394 30638 30638 F DEBUG : signal 0 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr --------
09-09 15:49:41.394 30638 30638 F DEBUG : x0 ffffffffffffffff x1 0000000000001000 x2 0000000000000007 x3 00007d7c8e7ff950
09-09 15:49:41.394 30638 30638 F DEBUG : x4 00007d7c8e7ff634 x5 00007d7c8e7ff9b4 x6 0000000000000033 x7 203d20544e495f4b
09-09 15:49:41.394 30638 30638 F DEBUG : x8 00004000206fd000 x9 d65f03c0a8c17bfd x10 0000000000000035 x11 00000000ffffffff
09-09 15:49:41.394 30638 30638 F DEBUG : x12 00007d7c8e7ff634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7cdf32c000 x19 00007d7c92f0567c
09-09 15:49:41.394 30638 30638 F DEBUG : x20 00004000206fc000 x21 00007d7c8e7fffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:49:41.394 30638 30638 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8e7fffb8 x27 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG : x28 0000000000000000 x29 00007d7c8e7ffea0 lr 00004000206d3124 sp 00007d7c8e7ffe00
09-09 15:49:41.394 30638 30638 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG :
09-09 15:49:41.394 30638 30638 F DEBUG : 3 total frames
09-09 15:49:41.394 30638 30638 F DEBUG : backtrace:
09-09 15:49:41.394 30638 30638 F DEBUG : #00 pc 0000000000013150 /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.394 30638 30638 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.394 30638 30638 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:49:41.512 30740 30740 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:49:41.512 30740 30740 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:49:41.512 30740 30740 F DEBUG : Revision: '0'
09-09 15:49:41.512 30740 30740 F DEBUG : ABI: 'x86_64'
09-09 15:49:41.512 30740 30740 F DEBUG : Timestamp: 2026-09-09 15:49:41.430196230+0800
09-09 15:49:41.512 30740 30740 F DEBUG : Process uptime: 2s
09-09 15:49:41.512 30740 30740 F DEBUG : Cmdline: dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:41.512 30740 30740 F DEBUG : pid: 30638, tid: 30638, name: folj.gfvrmybvrf >>> dark.ufnoinfolj.gfvrmybvrf <<<
09-09 15:49:41.512 30740 30740 F DEBUG : uid: 10067
09-09 15:49:41.512 30740 30740 F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00007d7c92f0567c
09-09 15:49:41.512 30740 30740 F DEBUG : rax 00007d7cdf2d4070 rbx 00007d7c934570d0 rcx 0000000000000001 rdx 00007d7fdbc73ba0
09-09 15:49:41.512 30740 30740 F DEBUG : r8 00007d7c9345e8c0 r9 00007d7c929e1220 r10 0000000000000000 r11 00007d7c93456f54
09-09 15:49:41.512 30740 30740 F DEBUG : r12 00007d7c92f20ea0 r13 00007d7cdf2d4070 r14 d65f03c0a8c17bfd r15 00007d7c92f0567c
09-09 15:49:41.512 30740 30740 F DEBUG : rdi 0000000080000009 rsi 00007d7cdf2d4420
09-09 15:49:41.512 30740 30740 F DEBUG : rbp 00007d7c93456f54 rsp 00007ffcf6298070 rip 00007d7c929e1304
09-09 15:49:41.512 30740 30740 F DEBUG : 4 total frames
09-09 15:49:41.512 30740 30740 F DEBUG : backtrace:
09-09 15:49:41.512 30740 30740 F DEBUG : #00 pc 0000000000131304 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:49:41.512 30740 30740 F DEBUG : #01 pc 00000000001d4aeb /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:49:41.512 30740 30740 F DEBUG : #02 pc 00000000000f0c8f /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
09-09 15:49:41.512 30740 30740 F DEBUG : #03 pc 0000000000318520 /system/lib64/libhoudini.so (BuildId: 1934525c179123ac41d7312a73c7a8bd50a7cffc)
+63
View File
@@ -0,0 +1,63 @@
# 黄果短剧启动闪退诊断
检查时间:2026-09-09Asia/Shanghai。
直接故障已确认:黄果短剧在应用加固库 `libjiaguv1.so``ndk_init` 初始化过程中发生原生崩溃。两份已安装应用均能稳定复现,尚未进入应用主界面。
现有证据最支持加固模块与当前 MuMu Android 15 / ARM64 转译运行环境不兼容。仅凭崩溃堆栈,无法最终区分加固库自身缺陷、转译层兼容问题或加固模块的环境检测;不能断言是故意封禁模拟器或 Root 检测。
## 实测结果
| 项目 | 第一份应用 | 第二份应用 |
| --- | --- | --- |
| 桌面名称 | 黄果短剧 | 黄果短剧 |
| 包名 | `dark.qgbevkxqql.ebyrhddxwd` | `dark.ufnoinfolj.gfvrmybvrf` |
| 版本 | 1.0.3 / versionCode 1 | 1.0.3 / versionCode 1 |
| 安装所选 ABI | arm64-v8a | arm64-v8a |
| SDK | minSdk 24 / targetSdk 36 | minSdk 24 / targetSdk 36 |
| 本次启动 | 15:48:22.014PID 30459 | 15:49:40.866PID 30638 |
| 致命信号 | 15:48:22.506 | 15:49:41.313 |
| 启动至致命信号 | 约 0.49 秒 | 约 0.45 秒 |
| 系统退出原因 | APP CRASH(NATIVE)status=11 | APP CRASH(NATIVE)status=11 |
两份应用的加固库 BuildId 相同:`cda80a0729e49c2a514407b56d77b8cb9c37e01e`,崩溃位置均为 `ndk_init+248`
关键堆栈:
```text
Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR)
#00 .../.jiagu/libjiaguv1.so (ndk_init+248)
#01 .../.jiagu/libjiaguv1.so (native_attach(...)+60)
#02 /system/lib64/libtcb.so
```
宿主 x86_64 崩溃堆栈同时落在 `/system/lib64/libhoudini.so`。应用进程日志显示它已启用 arm64 Native Bridge,并成功初始化 Houdini,随后加载加固库并在初始化阶段退出。证据支持崩溃发生于加固模块与 ARM 转译链路中,不代表已确定哪一方存在实现缺陷。
SIGSEGV 属于本地代码崩溃信号,参见 [Android 官方崩溃说明](https://developer.android.com/topic/performance/vitals/crash);堆栈阅读方法见 [AOSP 原生崩溃诊断](https://source.android.com/docs/core/tests/debug/native-crash)。官方资料用于解释日志类型,不是本应用兼容问题的官方确认。
## 环境与判断边界
- MuMu 6.6.0.0Android 15 / API 35,系统 x86_64ABI 列表为 `x86_64,arm64-v8a,x86`
- 两份应用安装所选 ABI 都是 arm64-v8a,在此实例中经 Houdini 转译运行。
- 宿主配置启用了 Root 模式;仅记录环境事实,没有证据证明 Root 是这次崩溃的触发条件。ADB shell 未取得 Root,也未访问受限的模块目录。
- 虚拟机和 Vulkan 渲染器均成功初始化。系统继续运行,两个目标应用进程分别崩溃。
- 本次故障发生于加固初始化阶段,日志不是连接超时或服务端错误;切换代理不针对当前已确认的崩溃点。
- 不能仅因 `targetSdk 36` 高于运行系统 API 35 就判定不兼容。应用已安装并进入启动流程。
- 没有读取应用源码、完整调试符号或进行其他系统版本/真机对照,因此不能声称已经修复,或保证换环境一定有效。
## 后续验证路径
1. 用同一安装包在 ARM64 安卓真机对照启动,判断是否仅在模拟器出现。
2. 用独立、干净的 Android 12/其他受支持版本模拟器实例对照启动,保留当前实例;这能帮助区分 Android 15、实例配置及转译兼容差异,尚未执行。
3. 若仍闪退,将加固库 BuildId、上述堆栈和系统版本交给应用开发方,要求检查或更新加固组件;安装经其确认兼容的版本。
## 本地证据
- `startup-a.txt``startup-b.txt`:本次两个应用进程的完整启动日志。
- `crash-reproduced-a.txt``crash-reproduced-b.txt`:本次采集的崩溃缓冲区片段。
- `exit-info-a.txt``exit-info-b.txt`:Android 系统记录的退出原因和历史复现。
- `package-a.txt``package-b.txt`:版本、ABI 和安装元数据。
- `host-findings.md`:独立核对的 MuMu 配置、虚拟化及图形日志证据。
- `crash-before.txt`:复现前保留的历史崩溃缓冲区。
本次仅打开应用复现并读取日志。没有卸载应用、清理应用数据、重启模拟器或修改代理、Root、系统设置。
@@ -0,0 +1,36 @@
ACTIVITY MANAGER PROCESS EXIT INFO (dumpsys activity exit-info)
Last Timestamp of Persistence Into Persistent Storage: 2026-09-09 15:42:23.036
package: dark.qgbevkxqql.ebyrhddxwd
Historical Process Exit for uid=10066
ApplicationExitInfo #0:
timestamp=2026-09-09 15:48:22.738 pid=30459 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #1:
timestamp=2026-09-09 15:44:58.101 pid=29666 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #2:
timestamp=2026-09-09 15:44:56.889 pid=29547 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #3:
timestamp=2026-09-09 15:44:55.604 pid=29417 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #4:
timestamp=2026-09-09 15:26:26.936 pid=27806 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #5:
timestamp=2026-09-09 15:13:14.322 pid=27353 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #6:
timestamp=2026-09-09 15:13:09.010 pid=27216 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #7:
timestamp=2026-09-09 15:12:45.763 pid=26418 realUid=10066 packageUid=10066 definingUid=10066 user=0
process=dark.qgbevkxqql.ebyrhddxwd reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
@@ -0,0 +1,20 @@
ACTIVITY MANAGER PROCESS EXIT INFO (dumpsys activity exit-info)
Last Timestamp of Persistence Into Persistent Storage: 2026-09-09 15:42:23.036
package: dark.ufnoinfolj.gfvrmybvrf
Historical Process Exit for uid=10067
ApplicationExitInfo #0:
timestamp=2026-09-09 15:49:41.520 pid=30638 realUid=10067 packageUid=10067 definingUid=10067 user=0
process=dark.ufnoinfolj.gfvrmybvrf reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #1:
timestamp=2026-09-09 15:45:00.019 pid=29794 realUid=10067 packageUid=10067 definingUid=10067 user=0
process=dark.ufnoinfolj.gfvrmybvrf reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #2:
timestamp=2026-09-09 15:44:53.805 pid=29290 realUid=10067 packageUid=10067 definingUid=10067 user=0
process=dark.ufnoinfolj.gfvrmybvrf reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
ApplicationExitInfo #3:
timestamp=2026-09-09 15:44:42.599 pid=28860 realUid=10067 packageUid=10067 definingUid=10067 user=0
process=dark.ufnoinfolj.gfvrmybvrf reason=5 (APP CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11
importance=100 pss=0.00 rss=0.00 description=crash state=empty trace=null
@@ -0,0 +1,35 @@
# 黄果短剧:MuMu 宿主只读排查
检查时间:2026-09-09(本地时区 Asia/Shanghai)。范围仅为 MuMu 安装目录中的配置和本次运行宿主日志;未调用 adb、未操作模拟器 UI、未修改模拟器设置。
## 已证实
- MuMu 产品版本 **6.6.0.0**;当前 15.0 设备组件版本 **15.6.0.5332**、ROM **152.0.43.004**、渲染器 **1.0.79**
- `D:\soft\MuMuPlayer\configs\main\install_config.json`67、182197 行。
- 当前实例 `MuMuPlayer-15.0-1` 使用 **Android 15 / x86_64**,配置 4 vCPU、6 GiB 内存。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\logs\shell.4.log`531 行报告 `android_version, 15.0`586 行含 `player_architecture ... x86_64`
- `D:\soft\MuMuPlayer\nx_device\15.0\configs\device\vms_config.json`4 行镜像名 `152.0.43.004-mumu15-default-x64-release`
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\configs\vm_config.json`11、16 行。
- **Root 模式已开启**。这只是明确的环境差异,不能据此断定应用因 Root 检测而退出。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\configs\vm_config.json`53 行 `"root": "true"`
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\configs\customer_config.json`135 行 `"root_mode": "1"`
- 虚拟机已成功运行,当前启动记录开始于 **2026-09-09 08:10:47**。宿主开启内存完整性;直接 VT-x 路径不可用,已回退到 Windows Hypervisor/NEM,随后进入 `Running`
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\logs\VBox.log`2 行日志 UTC 时间;16 行 `Memory Integrity: ENABLED`522 行 `Attempting fall back to NEM: VT-x is not available`537 行 `WHvCapabilityCodeHypervisorPresent is TRUE`700 行 `NEMR3Init: Snail execution mode is active!`1313 行 `Machine state changed to 'Running'`
- 日志包含启动期 MSR/I/O 映射错误(13351367 行),但之后 Android 和宿主持续工作。因此当前证据不支持“虚拟化完全无法启动”是黄果短剧打不开的原因。
- 图形后端为 **Vulkan 1.4.325 / Intel(R) Arc(TM) Graphics**,宿主驱动记录为 **32.0.101.8243**;渲染器初始化完成。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\configs\customer_config.json`235 行 `highperformance: Vulkan`
- `D:\soft\MuMuPlayer\nx_device\15.0\configs\device\shell_config.json`15 行驱动记录。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\logs\shell.4.log`791 行 Renderer 启动信息。
- `D:\soft\MuMuPlayer\nx_device\15.0\device\graphics_log\log.MuMuPlayer-15.0-1.txt`25 行 `FrameBuffer initialized successfully`2730 行 Renderer 初始化完成;1271 行 Vulkan 设备版本。
- 图形日志有找不到配置文件、NVAPI 设置失败等启动警告,随后渲染器初始化成功。未找到 `VK_ERROR_DEVICE_LOST` 或图形致命退出记录,不能将这些警告认定为本应用闪退原因。
- 宿主明确识别出“黄果短剧”的包名为 **`dark.qgbevkxqql.ebyrhddxwd`**,版本 **1.0.3 / versionCode 1**;发生多次启动后不到约 1 秒退出,并有 **AppCrash/APPCrashLog** 事件,属于应用级失败记录。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\logs\vboxmanager.log`191198 行记录 15:11:04 安装、15:11:06 启动、15:11:07 退出和崩溃;199204 行记录 15:11:13 再次启动、退出和崩溃。
- `D:\soft\MuMuPlayer\vms\MuMuPlayer-15.0-1\logs\shell.2.log`285 行 15:11:13.187 获焦;698 行 15:11:13.697 退出;2374、2806 行分别记录下一次启动和退出。
- `shell.2.log` 287 行记录应用组件标记 `com.knhbyg.lugybt.StubApp`。这只说明组件名称,不能仅凭名称推断使用了何种加固方案或具体反模拟器行为。
## 判断和待确认事项
1. 宿主能启动 Android、渲染器能工作,且明确记录目标包崩溃。排查重点应在 Android 侧崩溃堆栈和进程退出原因,而不是直接重装 MuMu 或关闭 Windows 安全功能。
2. **仅凭宿主日志无法区分** Android 15 兼容问题、x86_64/ARM 转译问题、Root/模拟器检测、应用自身缺陷或依赖异常。需要主代理的 Android 日志、包 ABI 和复现结果才能定因。
3. 宿主 `AppCrash` 事件只有事件元数据,未读取其上报到外部的崩溃附件;配置和日志里的设备标识、令牌、位置等非必要信息均未写入本报告。
4. 日志处于轮转中,以上行号对应本次读取版本。当前日志文件元数据曾显示长度为 0,但实际可读取内容,因此不能用其长度判断日志是否为空。
+237
View File
@@ -0,0 +1,237 @@
Activity Resolver Table:
Non-Data Actions:
android.intent.action.MAIN:
eecd61f dark.qgbevkxqql.ebyrhddxwd/com.xvproject.MainActivity filter f0ecd6c
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
96c8f35 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.icon_18 filter ed94fca
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
912b53b dark.qgbevkxqql.ebyrhddxwd/com.xvproject.icon_17 filter 6d15458
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
2e3e9b1 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.icon_16 filter c249e96
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
1ed6a17 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.yinyutusujiao filter d335e04
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
9c6fed dark.qgbevkxqql.ebyrhddxwd/com.xvproject.wendao filter b040e22
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
d5490b3 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.weichat filter e8a5670
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
1dd1de9 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.tuyou filter 578ea6e
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
afd850f dark.qgbevkxqql.ebyrhddxwd/com.xvproject.tiantianmajiang filter 516699c
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
960afa5 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.texuanxiaoshuo filter 24d3f7a
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
697632b dark.qgbevkxqql.ebyrhddxwd/com.xvproject.shuixinwuxian filter cee8388
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
d1a121 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.shenxuanzhizhan filter 8dfd946
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
da70707 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.myworld filter aac5034
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
8062e5d dark.qgbevkxqql.ebyrhddxwd/com.xvproject.meisijiejian filter 5b043d2
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
7230ca3 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.binggandazhuozhan filter d83ba0
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
bfc5359 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.bilibili filter baacb1e
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
8cfcfff dark.qgbevkxqql.ebyrhddxwd/com.xvproject.alipay filter 1571cc
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
195cc15 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.aiyuedu filter e347b2a
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
65b6d1b dark.qgbevkxqql.ebyrhddxwd/com.xvproject.aiyinyue filter e0ddeb8
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
141491 dark.qgbevkxqql.ebyrhddxwd/com.xvproject.lld filter ff71ff6
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
Receiver Resolver Table:
Non-Data Actions:
androidx.profileinstaller.action.SAVE_PROFILE:
639bff7 dark.qgbevkxqql.ebyrhddxwd/androidx.profileinstaller.ProfileInstallReceiver filter 26d4582
Action: "androidx.profileinstaller.action.SAVE_PROFILE"
androidx.profileinstaller.action.INSTALL_PROFILE:
639bff7 dark.qgbevkxqql.ebyrhddxwd/androidx.profileinstaller.ProfileInstallReceiver filter f1e2e64
Action: "androidx.profileinstaller.action.INSTALL_PROFILE"
androidx.profileinstaller.action.SKIP_FILE:
639bff7 dark.qgbevkxqql.ebyrhddxwd/androidx.profileinstaller.ProfileInstallReceiver filter 75468cd
Action: "androidx.profileinstaller.action.SKIP_FILE"
androidx.profileinstaller.action.BENCHMARK_OPERATION:
639bff7 dark.qgbevkxqql.ebyrhddxwd/androidx.profileinstaller.ProfileInstallReceiver filter 8406493
Action: "androidx.profileinstaller.action.BENCHMARK_OPERATION"
Service Resolver Table:
Non-Data Actions:
com.google.android.gms.metadata.MODULE_DEPENDENCIES:
ec1ccd0 dark.qgbevkxqql.ebyrhddxwd/com.google.android.gms.metadata.ModuleDependencies filter dcbc4c9
Action: "com.google.android.gms.metadata.MODULE_DEPENDENCIES"
Domain verification status:
Permissions:
Permission [dark.qgbevkxqql.ebyrhddxwd.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION] (1a6ebce):
sourcePackage=dark.qgbevkxqql.ebyrhddxwd
uid=10066 gids=[] type=0 prot=signature
perm=PermissionInfo{f3f5aef dark.qgbevkxqql.ebyrhddxwd.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION}
flags=0x0
Registered ContentProviders:
dark.qgbevkxqql.ebyrhddxwd/com.zaihui.installplugin.InstallFileProvider:
Provider{f88b9fc dark.qgbevkxqql.ebyrhddxwd/com.zaihui.installplugin.InstallFileProvider}
dark.qgbevkxqql.ebyrhddxwd/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider:
Provider{aca2885 dark.qgbevkxqql.ebyrhddxwd/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider}
dark.qgbevkxqql.ebyrhddxwd/androidx.startup.InitializationProvider:
Provider{c49f6da dark.qgbevkxqql.ebyrhddxwd/androidx.startup.InitializationProvider}
dark.qgbevkxqql.ebyrhddxwd/io.flutter.plugins.imagepicker.ImagePickerFileProvider:
Provider{b50b70b dark.qgbevkxqql.ebyrhddxwd/io.flutter.plugins.imagepicker.ImagePickerFileProvider}
dark.qgbevkxqql.ebyrhddxwd/androidx.core.content.FileProvider:
Provider{6a579e8 dark.qgbevkxqql.ebyrhddxwd/androidx.core.content.FileProvider}
ContentProvider Authorities:
[dark.qgbevkxqql.ebyrhddxwd.fileprovider]:
Provider{6a579e8 dark.qgbevkxqql.ebyrhddxwd/androidx.core.content.FileProvider}
applicationInfo=ApplicationInfo{109c801 dark.qgbevkxqql.ebyrhddxwd}
[dark.qgbevkxqql.ebyrhddxwd.flutter.image_provider]:
Provider{b50b70b dark.qgbevkxqql.ebyrhddxwd/io.flutter.plugins.imagepicker.ImagePickerFileProvider}
applicationInfo=ApplicationInfo{de0a6a6 dark.qgbevkxqql.ebyrhddxwd}
[dark.qgbevkxqql.ebyrhddxwd.androidx-startup]:
Provider{c49f6da dark.qgbevkxqql.ebyrhddxwd/androidx.startup.InitializationProvider}
applicationInfo=ApplicationInfo{5d1b8e7 dark.qgbevkxqql.ebyrhddxwd}
[dark.qgbevkxqql.ebyrhddxwd.flutter_inappwebview_android.fileprovider]:
Provider{aca2885 dark.qgbevkxqql.ebyrhddxwd/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider}
applicationInfo=ApplicationInfo{a4c4c94 dark.qgbevkxqql.ebyrhddxwd}
[dark.qgbevkxqql.ebyrhddxwd.installFileProvider.install]:
Provider{f88b9fc dark.qgbevkxqql.ebyrhddxwd/com.zaihui.installplugin.InstallFileProvider}
applicationInfo=ApplicationInfo{49e33d dark.qgbevkxqql.ebyrhddxwd}
Key Set Manager:
[dark.qgbevkxqql.ebyrhddxwd]
Signing KeySets: 33
Packages:
Package [dark.qgbevkxqql.ebyrhddxwd] (e08732):
appId=10066
pkg=Package{b76fc83 dark.qgbevkxqql.ebyrhddxwd}
codePath=/data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==
resourcePath=/data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==
legacyNativeLibraryDir=/data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/lib
extractNativeLibs=true
primaryCpuAbi=arm64-v8a
secondaryCpuAbi=null
cpuAbiOverride=null
versionCode=1 minSdk=24 targetSdk=36
minExtensionVersions=[]
versionName=1.0.3
hiddenApiEnforcementPolicy=2
usesNonSdkApi=false
splits=[base]
apkSigningVersion=3
flags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP LARGE_HEAP ]
privateFlags=[ PRIVATE_FLAG_ACTIVITIES_RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION ALLOW_AUDIO_PLAYBACK_CAPTURE PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE PRIVATE_FLAG_ALLOW_NATIVE_HEAP_POINTER_TAGGING ]
forceQueryable=false
pageSizeCompat=0
queriesPackages=[com.google.android.gms, com.android.vending, com.android.creator, com.mdid.msa, com.samsung.android.deviceidservice, com.coolpad.deviceidsupport, com.heytap.openid, com.huawei.hwid, com.huawei.hwid.tv, com.huawei.hms, com.asus.msa.SupplementaryDID, com.zui.deviceidservice]
queriesIntents=[Intent { act=android.support.customtabs.action.CustomTabsService }]
scannedAsStoppedSystemApp=false
supportsScreens=[small, medium, large, xlarge, resizeable, anyDensity]
usesOptionalLibraries:
androidx.window.extensions
androidx.window.sidecar
usesLibraryFiles:
/system_ext/framework/androidx.window.extensions.jar
/system_ext/framework/androidx.window.sidecar.jar
timeStamp=2026-09-09 15:12:41
lastUpdateTime=2026-09-09 15:12:43
installerPackageName=server-file-mumu-app-store
installerPackageUid=-1
initiatingPackageName=server-file-mumu-app-store
originatingPackageName=null
packageSource=1
appMetadataFilePath=null
appMetadataSource=0
signatures=PackageSignatures{2eb9e00 version:3, signatures:[82fff23a], past signatures:[]}
installPermissionsFixed=false
pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP LARGE_HEAP ]
privatePkgFlags=[ PRIVATE_FLAG_ACTIVITIES_RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION ALLOW_AUDIO_PLAYBACK_CAPTURE PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE PRIVATE_FLAG_ALLOW_NATIVE_HEAP_POINTER_TAGGING ]
apexModuleName=null
declared permissions:
dark.qgbevkxqql.ebyrhddxwd.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION: prot=signature
requested permissions:
android.permission.WRITE_SETTINGS
freemme.permission.msa
dark.qgbevkxqql.ebyrhddxwd.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
com.asus.msa.SupplementaryDID.ACCESS
android.permission.INTERNET
android.permission.READ_EXTERNAL_STORAGE
android.permission.READ_PHONE_STATE
android.permission.READ_PRIVILEGED_PHONE_STATE
android.permission.ACCESS_NETWORK_STATE
android.permission.CAMERA
android.permission.WRITE_EXTERNAL_STORAGE
android.permission.REQUEST_INSTALL_PACKAGES
android.permission.WAKE_LOCK
install permissions:
dark.qgbevkxqql.ebyrhddxwd.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION: granted=true
android.permission.INTERNET: granted=true
android.permission.ACCESS_NETWORK_STATE: granted=true
android.permission.WAKE_LOCK: granted=true
User 0: ceDataInode=4332793 deDataInode=1966082 installed=true hidden=false suspended=false distractionFlags=0 stopped=false notLaunched=false enabled=0 instant=false virtual=false quarantined=false
installReason=0
dataDir=/data/user/0/dark.qgbevkxqql.ebyrhddxwd
firstInstallTime=2026-09-09 15:12:43
uninstallReason=0
overlay paths:
/data/resource-cache/com.android.systemui-neutral-E1yJ.frro
/data/resource-cache/com.android.systemui-accent-XDUC.frro
/data/resource-cache/com.android.systemui-dynamic-GbHl.frro
lastDisabledCaller: server-file-mumu-app-store
gids=[3003]
runtime permissions:
android.permission.READ_EXTERNAL_STORAGE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED|RESTRICTION_INSTALLER_EXEMPT]
android.permission.READ_PHONE_STATE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.CAMERA: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.WRITE_EXTERNAL_STORAGE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED|RESTRICTION_INSTALLER_EXEMPT]
Queries:
system apps queryable: false
queries via forceQueryable:
queries via package name:
queries via component:
io.dcloud.HBuilder:
dark.qgbevkxqql.ebyrhddxwd
uni.app.UNIA031845:
dark.qgbevkxqql.ebyrhddxwd
com.tencent.mm:
dark.qgbevkxqql.ebyrhddxwd
com.android.settings.intelligence:
dark.qgbevkxqql.ebyrhddxwd
queryable via interaction:
User 0:
queryable via uses-library:
Dexopt state:
[dark.qgbevkxqql.ebyrhddxwd]
path: /data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/base.apk
x86_64: [status=verify] [reason=install] [primary-abi]
[location is /data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/oat/x86_64/base.odex]
Compiler stats:
[dark.qgbevkxqql.ebyrhddxwd]
base.apk - 99
+237
View File
@@ -0,0 +1,237 @@
Activity Resolver Table:
Non-Data Actions:
android.intent.action.MAIN:
3c941c5 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.MainActivity filter 219371a
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
ba2924b dark.ufnoinfolj.gfvrmybvrf/com.xvproject.icon_18 filter dcd5428
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
f013541 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.icon_17 filter 303aae6
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
a860827 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.icon_16 filter 6b78ad4
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
c6ae47d dark.ufnoinfolj.gfvrmybvrf/com.xvproject.yinyutusujiao filter cf8f72
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
a40ffc3 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.wendao filter f6c8040
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
1c64b79 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.weichat filter 50330be
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
c29d51f dark.ufnoinfolj.gfvrmybvrf/com.xvproject.tuyou filter 1b9606c
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
6d72635 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.tiantianmajiang filter 93d9aca
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
6a0a43b dark.ufnoinfolj.gfvrmybvrf/com.xvproject.texuanxiaoshuo filter 5121758
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
1e0f0b1 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.shuixinwuxian filter 29996
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
beb4917 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.shenxuanzhizhan filter 23d5104
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
fe2e6ed dark.ufnoinfolj.gfvrmybvrf/com.xvproject.myworld filter e46b922
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
2d15fb3 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.meisijiejian filter 5c07970
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
49404e9 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.binggandazhuozhan filter 1bb456e
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
cf8440f dark.ufnoinfolj.gfvrmybvrf/com.xvproject.bilibili filter d0bbc9c
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
e1f06a5 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.alipay filter fda4a7a
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
6ff122b dark.ufnoinfolj.gfvrmybvrf/com.xvproject.aiyuedu filter a660688
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
89e6821 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.aiyinyue filter 9b29446
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
e5aa607 dark.ufnoinfolj.gfvrmybvrf/com.xvproject.lld filter b990334
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LAUNCHER"
Receiver Resolver Table:
Non-Data Actions:
androidx.profileinstaller.action.SAVE_PROFILE:
d58655d dark.ufnoinfolj.gfvrmybvrf/androidx.profileinstaller.ProfileInstallReceiver filter f5d1ea0
Action: "androidx.profileinstaller.action.SAVE_PROFILE"
androidx.profileinstaller.action.INSTALL_PROFILE:
d58655d dark.ufnoinfolj.gfvrmybvrf/androidx.profileinstaller.ProfileInstallReceiver filter 73aed2
Action: "androidx.profileinstaller.action.INSTALL_PROFILE"
androidx.profileinstaller.action.SKIP_FILE:
d58655d dark.ufnoinfolj.gfvrmybvrf/androidx.profileinstaller.ProfileInstallReceiver filter 8f19ba3
Action: "androidx.profileinstaller.action.SKIP_FILE"
androidx.profileinstaller.action.BENCHMARK_OPERATION:
d58655d dark.ufnoinfolj.gfvrmybvrf/androidx.profileinstaller.ProfileInstallReceiver filter 9bafa59
Action: "androidx.profileinstaller.action.BENCHMARK_OPERATION"
Service Resolver Table:
Non-Data Actions:
com.google.android.gms.metadata.MODULE_DEPENDENCIES:
6b9e61e dark.ufnoinfolj.gfvrmybvrf/com.google.android.gms.metadata.ModuleDependencies filter a784eff
Action: "com.google.android.gms.metadata.MODULE_DEPENDENCIES"
Domain verification status:
Permissions:
Permission [dark.ufnoinfolj.gfvrmybvrf.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION] (5a2dddf):
sourcePackage=dark.ufnoinfolj.gfvrmybvrf
uid=10067 gids=[] type=0 prot=signature
perm=PermissionInfo{f6c9a2c dark.ufnoinfolj.gfvrmybvrf.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION}
flags=0x0
Registered ContentProviders:
dark.ufnoinfolj.gfvrmybvrf/io.flutter.plugins.imagepicker.ImagePickerFileProvider:
Provider{23f3cf5 dark.ufnoinfolj.gfvrmybvrf/io.flutter.plugins.imagepicker.ImagePickerFileProvider}
dark.ufnoinfolj.gfvrmybvrf/androidx.core.content.FileProvider:
Provider{3fdca8a dark.ufnoinfolj.gfvrmybvrf/androidx.core.content.FileProvider}
dark.ufnoinfolj.gfvrmybvrf/com.zaihui.installplugin.InstallFileProvider:
Provider{78178fb dark.ufnoinfolj.gfvrmybvrf/com.zaihui.installplugin.InstallFileProvider}
dark.ufnoinfolj.gfvrmybvrf/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider:
Provider{21f2d18 dark.ufnoinfolj.gfvrmybvrf/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider}
dark.ufnoinfolj.gfvrmybvrf/androidx.startup.InitializationProvider:
Provider{f93b371 dark.ufnoinfolj.gfvrmybvrf/androidx.startup.InitializationProvider}
ContentProvider Authorities:
[dark.ufnoinfolj.gfvrmybvrf.fileprovider]:
Provider{3fdca8a dark.ufnoinfolj.gfvrmybvrf/androidx.core.content.FileProvider}
applicationInfo=ApplicationInfo{cf60556 dark.ufnoinfolj.gfvrmybvrf}
[dark.ufnoinfolj.gfvrmybvrf.installFileProvider.install]:
Provider{78178fb dark.ufnoinfolj.gfvrmybvrf/com.zaihui.installplugin.InstallFileProvider}
applicationInfo=ApplicationInfo{e4a9d7 dark.ufnoinfolj.gfvrmybvrf}
[dark.ufnoinfolj.gfvrmybvrf.flutter.image_provider]:
Provider{23f3cf5 dark.ufnoinfolj.gfvrmybvrf/io.flutter.plugins.imagepicker.ImagePickerFileProvider}
applicationInfo=ApplicationInfo{6602c4 dark.ufnoinfolj.gfvrmybvrf}
[dark.ufnoinfolj.gfvrmybvrf.androidx-startup]:
Provider{f93b371 dark.ufnoinfolj.gfvrmybvrf/androidx.startup.InitializationProvider}
applicationInfo=ApplicationInfo{7df15ad dark.ufnoinfolj.gfvrmybvrf}
[dark.ufnoinfolj.gfvrmybvrf.flutter_inappwebview_android.fileprovider]:
Provider{21f2d18 dark.ufnoinfolj.gfvrmybvrf/com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFileProvider}
applicationInfo=ApplicationInfo{36520e2 dark.ufnoinfolj.gfvrmybvrf}
Key Set Manager:
[dark.ufnoinfolj.gfvrmybvrf]
Signing KeySets: 34
Packages:
Package [dark.ufnoinfolj.gfvrmybvrf] (2e00c73):
appId=10067
pkg=Package{1228730 dark.ufnoinfolj.gfvrmybvrf}
codePath=/data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==
resourcePath=/data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==
legacyNativeLibraryDir=/data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/lib
extractNativeLibs=true
primaryCpuAbi=arm64-v8a
secondaryCpuAbi=null
cpuAbiOverride=null
versionCode=1 minSdk=24 targetSdk=36
minExtensionVersions=[]
versionName=1.0.3
hiddenApiEnforcementPolicy=2
usesNonSdkApi=false
splits=[base]
apkSigningVersion=3
flags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP LARGE_HEAP ]
privateFlags=[ PRIVATE_FLAG_ACTIVITIES_RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION ALLOW_AUDIO_PLAYBACK_CAPTURE PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE PRIVATE_FLAG_ALLOW_NATIVE_HEAP_POINTER_TAGGING ]
forceQueryable=false
pageSizeCompat=0
queriesPackages=[com.google.android.gms, com.android.vending, com.android.creator, com.mdid.msa, com.samsung.android.deviceidservice, com.coolpad.deviceidsupport, com.heytap.openid, com.huawei.hwid, com.huawei.hwid.tv, com.huawei.hms, com.asus.msa.SupplementaryDID, com.zui.deviceidservice]
queriesIntents=[Intent { act=android.support.customtabs.action.CustomTabsService }]
scannedAsStoppedSystemApp=false
supportsScreens=[small, medium, large, xlarge, resizeable, anyDensity]
usesOptionalLibraries:
androidx.window.extensions
androidx.window.sidecar
usesLibraryFiles:
/system_ext/framework/androidx.window.extensions.jar
/system_ext/framework/androidx.window.sidecar.jar
timeStamp=2026-09-09 15:44:37
lastUpdateTime=2026-09-09 15:44:40
installerPackageName=server-file-mumu-app-store
installerPackageUid=-1
initiatingPackageName=server-file-mumu-app-store
originatingPackageName=null
packageSource=1
appMetadataFilePath=null
appMetadataSource=0
signatures=PackageSignatures{45fa9 version:3, signatures:[6e1acd18], past signatures:[]}
installPermissionsFixed=false
pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP LARGE_HEAP ]
privatePkgFlags=[ PRIVATE_FLAG_ACTIVITIES_RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION ALLOW_AUDIO_PLAYBACK_CAPTURE PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE PRIVATE_FLAG_ALLOW_NATIVE_HEAP_POINTER_TAGGING ]
apexModuleName=null
declared permissions:
dark.ufnoinfolj.gfvrmybvrf.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION: prot=signature
requested permissions:
android.permission.WRITE_SETTINGS
freemme.permission.msa
com.asus.msa.SupplementaryDID.ACCESS
android.permission.INTERNET
android.permission.READ_EXTERNAL_STORAGE
android.permission.READ_PHONE_STATE
android.permission.READ_PRIVILEGED_PHONE_STATE
android.permission.ACCESS_NETWORK_STATE
android.permission.CAMERA
dark.ufnoinfolj.gfvrmybvrf.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
android.permission.WRITE_EXTERNAL_STORAGE
android.permission.REQUEST_INSTALL_PACKAGES
android.permission.WAKE_LOCK
install permissions:
android.permission.INTERNET: granted=true
android.permission.ACCESS_NETWORK_STATE: granted=true
dark.ufnoinfolj.gfvrmybvrf.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION: granted=true
android.permission.WAKE_LOCK: granted=true
User 0: ceDataInode=4332921 deDataInode=1966085 installed=true hidden=false suspended=false distractionFlags=0 stopped=false notLaunched=false enabled=0 instant=false virtual=false quarantined=false
installReason=0
dataDir=/data/user/0/dark.ufnoinfolj.gfvrmybvrf
firstInstallTime=2026-09-09 15:44:40
uninstallReason=0
overlay paths:
/data/resource-cache/com.android.systemui-neutral-E1yJ.frro
/data/resource-cache/com.android.systemui-accent-XDUC.frro
/data/resource-cache/com.android.systemui-dynamic-GbHl.frro
lastDisabledCaller: server-file-mumu-app-store
gids=[3003]
runtime permissions:
android.permission.READ_EXTERNAL_STORAGE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED|RESTRICTION_INSTALLER_EXEMPT]
android.permission.READ_PHONE_STATE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.CAMERA: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.WRITE_EXTERNAL_STORAGE: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED|RESTRICTION_INSTALLER_EXEMPT]
Queries:
system apps queryable: false
queries via forceQueryable:
queries via package name:
queries via component:
io.dcloud.HBuilder:
dark.ufnoinfolj.gfvrmybvrf
uni.app.UNIA031845:
dark.ufnoinfolj.gfvrmybvrf
com.tencent.mm:
dark.ufnoinfolj.gfvrmybvrf
com.android.settings.intelligence:
dark.ufnoinfolj.gfvrmybvrf
queryable via interaction:
User 0:
queryable via uses-library:
Dexopt state:
[dark.ufnoinfolj.gfvrmybvrf]
path: /data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/base.apk
x86_64: [status=verify] [reason=install] [primary-abi]
[location is /data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/oat/x86_64/base.odex]
Compiler stats:
[dark.ufnoinfolj.gfvrmybvrf]
base.apk - 94
@@ -0,0 +1,85 @@
--------- beginning of main
09-09 15:48:22.024 30459 30459 W nativebridge: Failed to bind-mount /system/etc/cpuinfo.arm64.txt as /proc/cpuinfo: No such file or directory
09-09 15:48:22.024 30459 30459 E Zygote : pkg name is 0 => dark.qgbevkxqql.ebyrhddxwd, data dir is /data/user/0/dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.026 30459 30459 W zygote64: Thread Pool max thread count is 0. Cannot cache binder as linkToDeath cannot be implemented. serviceName: nemuhotd
09-09 15:48:22.073 30459 30459 I Zygote : seccomp disabled by setenforce 0
09-09 15:48:22.080 30459 30459 I xqql.ebyrhddxwd: Using CollectorTypeCMC GC.
09-09 15:48:22.080 30459 30459 W xqql.ebyrhddxwd: Unexpected CPU variant for x86: x86_64.
09-09 15:48:22.080 30459 30459 W xqql.ebyrhddxwd: Known variants: atom, sandybridge, silvermont, goldmont, goldmont-plus, goldmont-without-sha-xsaves, tremont, kabylake, alderlake, default
09-09 15:48:22.081 30459 30459 V libnb : enter native_bridge2_initialize /data/user/0/dark.qgbevkxqql.ebyrhddxwd/code_cache arm64
09-09 15:48:22.081 30459 30459 E libnb : The translator implementation library has not been set.
09-09 15:48:22.088 30459 30459 E libnb : get_callbacks native_handle 0x60dcbe63aa6b753f: 1 libnb:/system/lib64/libhoudini.so
09-09 15:48:22.088 30459 30459 I libnb : Found /system/lib64/libhoudini.so version 7
09-09 15:48:22.088 30459 30459 E HP : patch_houdini_linker_null_crash: patched +0x2FFECE (je->jmp) OK
09-09 15:48:22.089 30459 30459 D HP : no libmumuhooker.so
09-09 15:48:22.087 30459 30459 I xqql.ebyrhddxwd: type=1400 audit(0.0:12150): avc: denied { write } for name="property_service" dev="tmpfs" ino=634 scontext=u:r:untrusted_app:s0:c66,c256,c512,c768 tcontext=u:object_r:property_socket:s0 tclass=sock_file permissive=1 app=dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.087 30459 30459 I xqql.ebyrhddxwd: type=1400 audit(0.0:12151): avc: denied { connectto } for path="/dev/socket/property_service" scontext=u:r:untrusted_app:s0:c66,c256,c512,c768 tcontext=u:r:init:s0 tclass=unix_stream_socket permissive=1 app=dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.370 30459 30459 D HP : mAbi = arm64 enableHighFrame = 0
09-09 15:48:22.370 30459 30459 D houdini : [30459] Houdini now in android app mode
09-09 15:48:22.370 30459 30459 D houdini : [30459] Intel Bridge Technology for Netease Mumu only, Copyright(C) Intel Corp. 2012-2025.
09-09 15:48:22.370 30459 30459 D houdini : [30459] Use permitted only under a valid commercial software license agreement - all other rights reserved.
09-09 15:48:22.370 30459 30459 D houdini : [30459] Initialize library(version: 15.0.0_z.Netease_com2.0 RELEASE)... successfully.
09-09 15:48:22.370 30459 30459 W HP : x_initialize private_dir:/data/user/0/dark.qgbevkxqql.ebyrhddxwd/code_cache instruction_set:arm64 getMethodShorty:0x7d7d0f654700 ret:1
09-09 15:48:22.372 30459 30459 E xqql.ebyrhddxwd: Not starting debugger since process cannot load the jdwp agent.
09-09 15:48:22.375 30459 30459 D nativeloader: Load libframework-connectivity-tiramisu-jni.so using APEX ns com_android_tethering for caller /apex/com.android.tethering/javalib/framework-connectivity-t.jar: ok
09-09 15:48:22.406 30459 30459 D ApplicationLoaders: Returning zygote-cached class loader: /system_ext/framework/androidx.window.extensions.jar
09-09 15:48:22.406 30459 30459 D ApplicationLoaders: Returning zygote-cached class loader: /system_ext/framework/androidx.window.sidecar.jar
09-09 15:48:22.408 30459 30459 D nativeloader: Configuring clns-7 for other apk /data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/base.apk. target_sdk_version=36, uses_libraries=, library_path=/data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/lib/arm64:/data/app/~~Nv3Pkb8JxFbq9tkCUcFWig==/dark.qgbevkxqql.ebyrhddxwd-hjya32oTAbGkp4lsSWq3AQ==/base.apk!/lib/arm64-v8a, permitted_path=/data:/mnt/expand:/data/user/0/dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.411 30459 30459 I xqql.ebyrhddxwd: AssetManager2(0x7d7e438f53f8) locale list changing from [] to [zh-Hans-CN]
09-09 15:48:22.414 30459 30459 V GraphicsEnvironment: Currently set values for:
09-09 15:48:22.414 30459 30459 V GraphicsEnvironment: angle_gl_driver_selection_pkgs=[]
09-09 15:48:22.414 30459 30459 V GraphicsEnvironment: angle_gl_driver_selection_values=[]
09-09 15:48:22.414 30459 30459 V GraphicsEnvironment: dark.qgbevkxqql.ebyrhddxwd is not listed in per-application setting
09-09 15:48:22.414 30459 30459 V GraphicsEnvironment: Neither updatable production driver nor prerelease driver is supported.
09-09 15:48:22.416 30459 30459 W NDK_JIAGU: soName:libjiagu_64.so
09-09 15:48:22.498 30459 30459 D NDK_JIAGU: SDK_INT = 35
09-09 15:48:22.499 30459 30459 D houdini : [30459] r00:ffffffffffffffff r01:0000000000001000 r02:0000000000000007
09-09 15:48:22.499 30459 30459 D houdini : [30459] r03:00007d7c8f83f950 r04:00007d7c8f83f634 r05:00007d7c8f83f9b4
09-09 15:48:22.499 30459 30459 D houdini : [30459] r06:0000000000000033 r07:203d20544e495f4b r08:00004000206fd000
09-09 15:48:22.499 30459 30459 D houdini : [30459] r09:d65f03c0a8c17bfd r10:0000000000000035 r11:00000000ffffffff
09-09 15:48:22.499 30459 30459 D houdini : [30459] r12:00007d7c8f83f634 r13:0000000000000000 r14:0000000000000001
09-09 15:48:22.499 30459 30459 D houdini : [30459] r15:0000000000000000 r16:0000400020512ee8 r17:000040002049b39c
09-09 15:48:22.499 30459 30459 D houdini : [30459] r18:00007d7c8ff2c000 r19:00007d7c9286167c r20:00004000206fc000
09-09 15:48:22.499 30459 30459 D houdini : [30459] r21:00007d7c8f83ffb8 r22:00004000206fd000 r23:0000000000001000
09-09 15:48:22.499 30459 30459 D houdini : [30459] r24:0000000000000000 r25:0000000000000000 r26:00007d7c8f83ffb8
09-09 15:48:22.499 30459 30459 D houdini : [30459] r27:0000000000000000 r28:0000000000000000 r29:00007d7c8f83fea0
09-09 15:48:22.499 30459 30459 D houdini : [30459] r30:00004000206d3124 sp:00007d7c8f83fe00 pc:00004000206d3150
09-09 15:48:22.506 30459 30459 D houdini : [30459]
09-09 15:48:22.506 30459 30459 D houdini : [30459] arm backtrace: pc = 00004000206d3150, lr = 00004000206d3124
09-09 15:48:22.506 30459 30459 D houdini : [30459] #00 pc 000000000001314c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (ndk_init+244) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.506 30459 30459 D houdini : [30459] #01 pc 000000000000fe4c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (_Z13native_attachP7_JNIEnvP7_jclassP8_jobject+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.506 30459 30459 D houdini : [30459]
--------- beginning of crash
09-09 15:48:22.506 30459 30459 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7d7c9286167c in tid 30459 (xqql.ebyrhddxwd), pid 30459 (xqql.ebyrhddxwd)
09-09 15:48:22.506 30459 30459 I SIGNAL_LOG: signal pid 30459 uid 10066 signal_num 11 si_code 2 si_errno 0 si_val 0
09-09 15:48:22.511 30459 30459 I SIGNAL_LOG: houdini base 0x7d7c9220c000
09-09 15:48:22.511 30459 30459 I SIGNAL_LOG: houdini arm64 regs base 0x7d7c8fed4070
09-09 15:48:22.511 30459 30459 I SIGNAL_LOG: dump signal file fd: 78 path: /data/tombstones/app/dark.qgbevkxqql.ebyrhddxwd/10066.log
09-09 15:48:22.507 30459 30459 I xqql.ebyrhddxwd: type=1400 audit(0.0:12154): avc: denied { write } for name="10066.log" dev="sdc3" ino=1310931 scontext=u:r:untrusted_app:s0:c66,c256,c512,c768 tcontext=u:object_r:tombstone_data_file:s0 tclass=file permissive=1 app=dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.515 30459 30459 I xqql.ebyrhddxwd: type=1400 audit(0.0:12155): avc: denied { read } for name="u:object_r:misctrl_prop:s0" dev="tmpfs" ino=375 scontext=u:r:untrusted_app:s0:c66,c256,c512,c768 tcontext=u:object_r:misctrl_prop:s0 tclass=file permissive=1 app=dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.598 30459 30459 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:48:22.598 30459 30459 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:48:22.598 30459 30459 F DEBUG : Revision: '0'
09-09 15:48:22.598 30459 30459 F DEBUG : ABI: 'x86_64'
09-09 15:48:22.598 30459 30459 F DEBUG : Timestamp: 2026-09-09 15:48:22.517224610+0800
09-09 15:48:22.598 30459 30459 F DEBUG : Process uptime: 2s
09-09 15:48:22.598 30459 30459 F DEBUG : Cmdline: dark.qgbevkxqql.ebyrhddxwd
09-09 15:48:22.598 30459 30459 F DEBUG : pid: 30459, tid: 30459, name: xqql.ebyrhddxwd >>> dark.qgbevkxqql.ebyrhddxwd <<<
09-09 15:48:22.598 30459 30459 F DEBUG : uid: 10066
09-09 15:48:22.598 30459 30459 F DEBUG : signal 0 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr --------
09-09 15:48:22.598 30459 30459 F DEBUG : x0 ffffffffffffffff x1 0000000000001000 x2 0000000000000007 x3 00007d7c8f83f950
09-09 15:48:22.598 30459 30459 F DEBUG : x4 00007d7c8f83f634 x5 00007d7c8f83f9b4 x6 0000000000000033 x7 203d20544e495f4b
09-09 15:48:22.598 30459 30459 F DEBUG : x8 00004000206fd000 x9 d65f03c0a8c17bfd x10 0000000000000035 x11 00000000ffffffff
09-09 15:48:22.598 30459 30459 F DEBUG : x12 00007d7c8f83f634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7c8ff2c000 x19 00007d7c9286167c
09-09 15:48:22.598 30459 30459 F DEBUG : x20 00004000206fc000 x21 00007d7c8f83ffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:48:22.598 30459 30459 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8f83ffb8 x27 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG : x28 0000000000000000 x29 00007d7c8f83fea0 lr 00004000206d3124 sp 00007d7c8f83fe00
09-09 15:48:22.598 30459 30459 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:48:22.598 30459 30459 F DEBUG :
09-09 15:48:22.598 30459 30459 F DEBUG : 3 total frames
09-09 15:48:22.598 30459 30459 F DEBUG : backtrace:
09-09 15:48:22.598 30459 30459 F DEBUG : #00 pc 0000000000013150 /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.qgbevkxqql.ebyrhddxwd/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:48:22.598 30459 30459 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:48:22.606 30459 30459 I SIGNAL_LOG: dump signal file ok! path: /data/tombstones/app/dark.qgbevkxqql.ebyrhddxwd/10066.log
@@ -0,0 +1,83 @@
--------- beginning of main
09-09 15:49:40.896 30638 30638 W nativebridge: Failed to bind-mount /system/etc/cpuinfo.arm64.txt as /proc/cpuinfo: No such file or directory
09-09 15:49:40.896 30638 30638 E Zygote : pkg name is 0 => dark.ufnoinfolj.gfvrmybvrf, data dir is /data/user/0/dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:40.897 30638 30638 W zygote64: Thread Pool max thread count is 0. Cannot cache binder as linkToDeath cannot be implemented. serviceName: nemuhotd
09-09 15:49:40.935 30638 30638 I Zygote : seccomp disabled by setenforce 0
09-09 15:49:40.940 30638 30638 I folj.gfvrmybvrf: Using CollectorTypeCMC GC.
09-09 15:49:40.941 30638 30638 W folj.gfvrmybvrf: Unexpected CPU variant for x86: x86_64.
09-09 15:49:40.941 30638 30638 W folj.gfvrmybvrf: Known variants: atom, sandybridge, silvermont, goldmont, goldmont-plus, goldmont-without-sha-xsaves, tremont, kabylake, alderlake, default
09-09 15:49:40.941 30638 30638 V libnb : enter native_bridge2_initialize /data/user/0/dark.ufnoinfolj.gfvrmybvrf/code_cache arm64
09-09 15:49:40.941 30638 30638 E libnb : The translator implementation library has not been set.
09-09 15:49:40.949 30638 30638 E libnb : get_callbacks native_handle 0xb23a64f73fb9e05b: 1 libnb:/system/lib64/libhoudini.so
09-09 15:49:40.949 30638 30638 I libnb : Found /system/lib64/libhoudini.so version 7
09-09 15:49:40.950 30638 30638 E HP : patch_houdini_linker_null_crash: patched +0x2FFECE (je->jmp) OK
09-09 15:49:40.950 30638 30638 D HP : no libmumuhooker.so
09-09 15:49:40.947 30638 30638 I folj.gfvrmybvrf: type=1400 audit(0.0:12180): avc: denied { write } for name="property_service" dev="tmpfs" ino=634 scontext=u:r:untrusted_app:s0:c67,c256,c512,c768 tcontext=u:object_r:property_socket:s0 tclass=sock_file permissive=1 app=dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:40.947 30638 30638 I folj.gfvrmybvrf: type=1400 audit(0.0:12181): avc: denied { connectto } for path="/dev/socket/property_service" scontext=u:r:untrusted_app:s0:c67,c256,c512,c768 tcontext=u:r:init:s0 tclass=unix_stream_socket permissive=1 app=dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:41.189 30638 30638 D HP : mAbi = arm64 enableHighFrame = 0
09-09 15:49:41.190 30638 30638 D houdini : [30638] Houdini now in android app mode
09-09 15:49:41.190 30638 30638 D houdini : [30638] Intel Bridge Technology for Netease Mumu only, Copyright(C) Intel Corp. 2012-2025.
09-09 15:49:41.190 30638 30638 D houdini : [30638] Use permitted only under a valid commercial software license agreement - all other rights reserved.
09-09 15:49:41.190 30638 30638 D houdini : [30638] Initialize library(version: 15.0.0_z.Netease_com2.0 RELEASE)... successfully.
09-09 15:49:41.190 30638 30638 W HP : x_initialize private_dir:/data/user/0/dark.ufnoinfolj.gfvrmybvrf/code_cache instruction_set:arm64 getMethodShorty:0x7d7d0f654700 ret:1
09-09 15:49:41.192 30638 30638 E folj.gfvrmybvrf: Not starting debugger since process cannot load the jdwp agent.
09-09 15:49:41.194 30638 30638 D nativeloader: Load libframework-connectivity-tiramisu-jni.so using APEX ns com_android_tethering for caller /apex/com.android.tethering/javalib/framework-connectivity-t.jar: ok
09-09 15:49:41.219 30638 30638 D ApplicationLoaders: Returning zygote-cached class loader: /system_ext/framework/androidx.window.extensions.jar
09-09 15:49:41.219 30638 30638 D ApplicationLoaders: Returning zygote-cached class loader: /system_ext/framework/androidx.window.sidecar.jar
09-09 15:49:41.221 30638 30638 D nativeloader: Configuring clns-7 for other apk /data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/base.apk. target_sdk_version=36, uses_libraries=, library_path=/data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/lib/arm64:/data/app/~~KwPgBKk5gcLlKqmLSrCNfg==/dark.ufnoinfolj.gfvrmybvrf-V4Iz1jL_s6MZ3WUZ65cfeQ==/base.apk!/lib/arm64-v8a, permitted_path=/data:/mnt/expand:/data/user/0/dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:41.222 30638 30638 I folj.gfvrmybvrf: AssetManager2(0x7d7e438eb498) locale list changing from [] to [zh-Hans-CN]
09-09 15:49:41.224 30638 30638 V GraphicsEnvironment: Currently set values for:
09-09 15:49:41.224 30638 30638 V GraphicsEnvironment: angle_gl_driver_selection_pkgs=[]
09-09 15:49:41.224 30638 30638 V GraphicsEnvironment: angle_gl_driver_selection_values=[]
09-09 15:49:41.225 30638 30638 V GraphicsEnvironment: dark.ufnoinfolj.gfvrmybvrf is not listed in per-application setting
09-09 15:49:41.225 30638 30638 V GraphicsEnvironment: Neither updatable production driver nor prerelease driver is supported.
09-09 15:49:41.227 30638 30638 W NDK_JIAGU: soName:libjiagu_64.so
09-09 15:49:41.305 30638 30638 D NDK_JIAGU: SDK_INT = 35
09-09 15:49:41.306 30638 30638 D houdini : [30638] r00:ffffffffffffffff r01:0000000000001000 r02:0000000000000007
09-09 15:49:41.306 30638 30638 D houdini : [30638] r03:00007d7c8e7ff950 r04:00007d7c8e7ff634 r05:00007d7c8e7ff9b4
09-09 15:49:41.306 30638 30638 D houdini : [30638] r06:0000000000000033 r07:203d20544e495f4b r08:00004000206fd000
09-09 15:49:41.306 30638 30638 D houdini : [30638] r09:d65f03c0a8c17bfd r10:0000000000000035 r11:00000000ffffffff
09-09 15:49:41.306 30638 30638 D houdini : [30638] r12:00007d7c8e7ff634 r13:0000000000000000 r14:0000000000000001
09-09 15:49:41.306 30638 30638 D houdini : [30638] r15:0000000000000000 r16:0000400020512ee8 r17:000040002049b39c
09-09 15:49:41.306 30638 30638 D houdini : [30638] r18:00007d7cdf32c000 r19:00007d7c92f0567c r20:00004000206fc000
09-09 15:49:41.306 30638 30638 D houdini : [30638] r21:00007d7c8e7fffb8 r22:00004000206fd000 r23:0000000000001000
09-09 15:49:41.306 30638 30638 D houdini : [30638] r24:0000000000000000 r25:0000000000000000 r26:00007d7c8e7fffb8
09-09 15:49:41.306 30638 30638 D houdini : [30638] r27:0000000000000000 r28:0000000000000000 r29:00007d7c8e7ffea0
09-09 15:49:41.306 30638 30638 D houdini : [30638] r30:00004000206d3124 sp:00007d7c8e7ffe00 pc:00004000206d3150
09-09 15:49:41.312 30638 30638 D houdini : [30638]
09-09 15:49:41.313 30638 30638 D houdini : [30638] arm backtrace: pc = 00004000206d3150, lr = 00004000206d3124
09-09 15:49:41.313 30638 30638 D houdini : [30638] #00 pc 000000000001314c /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (ndk_init+244) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.313 30638 30638 D houdini : [30638] #01 pc 000000000000fe4c /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (_Z13native_attachP7_JNIEnvP7_jclassP8_jobject+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.313 30638 30638 D houdini : [30638]
--------- beginning of crash
09-09 15:49:41.313 30638 30638 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7d7c92f0567c in tid 30638 (folj.gfvrmybvrf), pid 30638 (folj.gfvrmybvrf)
09-09 15:49:41.313 30638 30638 I SIGNAL_LOG: signal pid 30638 uid 10067 signal_num 11 si_code 2 si_errno 0 si_val 0
09-09 15:49:41.316 30638 30638 I SIGNAL_LOG: houdini base 0x7d7c928b0000
09-09 15:49:41.316 30638 30638 I SIGNAL_LOG: houdini arm64 regs base 0x7d7cdf2d4070
09-09 15:49:41.317 30638 30638 I SIGNAL_LOG: dump signal file fd: 78 path: /data/tombstones/app/dark.ufnoinfolj.gfvrmybvrf/10067.log
09-09 15:49:41.393 30638 30638 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
09-09 15:49:41.393 30638 30638 F DEBUG : Build fingerprint: 'HUAWEI/Nicole/Nicole:15/V417IR/972:user/release-keys'
09-09 15:49:41.393 30638 30638 F DEBUG : Revision: '0'
09-09 15:49:41.393 30638 30638 F DEBUG : ABI: 'x86_64'
09-09 15:49:41.394 30638 30638 F DEBUG : Timestamp: 2026-09-09 15:49:41.321662396+0800
09-09 15:49:41.394 30638 30638 F DEBUG : Process uptime: 1s
09-09 15:49:41.394 30638 30638 F DEBUG : Cmdline: dark.ufnoinfolj.gfvrmybvrf
09-09 15:49:41.394 30638 30638 F DEBUG : pid: 30638, tid: 30638, name: folj.gfvrmybvrf >>> dark.ufnoinfolj.gfvrmybvrf <<<
09-09 15:49:41.394 30638 30638 F DEBUG : uid: 10067
09-09 15:49:41.394 30638 30638 F DEBUG : signal 0 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr --------
09-09 15:49:41.394 30638 30638 F DEBUG : x0 ffffffffffffffff x1 0000000000001000 x2 0000000000000007 x3 00007d7c8e7ff950
09-09 15:49:41.394 30638 30638 F DEBUG : x4 00007d7c8e7ff634 x5 00007d7c8e7ff9b4 x6 0000000000000033 x7 203d20544e495f4b
09-09 15:49:41.394 30638 30638 F DEBUG : x8 00004000206fd000 x9 d65f03c0a8c17bfd x10 0000000000000035 x11 00000000ffffffff
09-09 15:49:41.394 30638 30638 F DEBUG : x12 00007d7c8e7ff634 x13 0000000000000000 x14 0000000000000001 x15 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG : x16 0000400020512ee8 x17 000040002049b39c x18 00007d7cdf32c000 x19 00007d7c92f0567c
09-09 15:49:41.394 30638 30638 F DEBUG : x20 00004000206fc000 x21 00007d7c8e7fffb8 x22 00004000206fd000 x23 0000000000001000
09-09 15:49:41.394 30638 30638 F DEBUG : x24 0000000000000000 x25 0000000000000000 x26 00007d7c8e7fffb8 x27 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG : x28 0000000000000000 x29 00007d7c8e7ffea0 lr 00004000206d3124 sp 00007d7c8e7ffe00
09-09 15:49:41.394 30638 30638 F DEBUG : pc 00004000206d3150 pst 0000000000000000
09-09 15:49:41.394 30638 30638 F DEBUG :
09-09 15:49:41.394 30638 30638 F DEBUG : 3 total frames
09-09 15:49:41.394 30638 30638 F DEBUG : backtrace:
09-09 15:49:41.394 30638 30638 F DEBUG : #00 pc 0000000000013150 /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (ndk_init+248) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.394 30638 30638 F DEBUG : #01 pc 000000000000fe4c /data/data/dark.ufnoinfolj.gfvrmybvrf/.jiagu/libjiaguv1.so (native_attach(_JNIEnv*, _jclass*, _jobject*)+60) (BuildId: cda80a0729e49c2a514407b56d77b8cb9c37e01e)
09-09 15:49:41.394 30638 30638 F DEBUG : #02 pc 00000000000fc4fc /system/lib64/libtcb.so (offset 0xb4000)
09-09 15:49:41.402 30638 30638 I SIGNAL_LOG: dump signal file ok! path: /data/tombstones/app/dark.ufnoinfolj.gfvrmybvrf/10067.log
+22
View File
@@ -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/` 不存在。
+495
View File
@@ -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
+40
View File
@@ -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 消息键相同。
本报告是只读复核;唯一新增文件为本报告。
+495
View File
@@ -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
+62
View File
@@ -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 或修改腾讯控制台。

Some files were not shown because too many files have changed in this diff Show More