Compare commits
10
Commits
bd5d5c5f08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf474ddd7 | ||
|
|
bbe3e870a1 | ||
|
|
caab2cabe3 | ||
|
|
3c7b88bb15 | ||
|
|
36975c6c1b | ||
|
|
6325ba88ff | ||
|
|
f8b6196205 | ||
|
|
aa0d22bbe2 | ||
|
|
27fbef9321 | ||
|
|
cb10e75ead |
@@ -27,6 +27,7 @@ const callers = [
|
|||||||
]
|
]
|
||||||
for (const caller of callers) {
|
for (const caller of callers) {
|
||||||
assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/)
|
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')
|
const chat = read('components/chat-dialog/index.vue')
|
||||||
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
|
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
|
||||||
@@ -40,7 +41,8 @@ assert.match(form, /appointment_type: form\.appointmentType/)
|
|||||||
async function main() {
|
async function main() {
|
||||||
const components = [
|
const components = [
|
||||||
...callers, 'components/chat-dialog/index.vue',
|
...callers, 'components/chat-dialog/index.vue',
|
||||||
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue'
|
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue',
|
||||||
|
'views/first_visit/my_patients/index.vue', 'views/tcm/diagnosis/index.vue', 'views/tcm/diagnosis/index_h5.vue'
|
||||||
]
|
]
|
||||||
for (const filename of components) {
|
for (const filename of components) {
|
||||||
const { descriptor, errors } = parse(read(filename), { filename })
|
const { descriptor, errors } = parse(read(filename), { filename })
|
||||||
|
|||||||
+45
-7
@@ -275,14 +275,52 @@ export function generateOrderQrcode(params: any) {
|
|||||||
|
|
||||||
// ========== IM / 企业微信聊天记录 ==========
|
// ========== IM / 企业微信聊天记录 ==========
|
||||||
|
|
||||||
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_*) */
|
export interface ImChatMessagesResponse {
|
||||||
export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) {
|
lists: any[]
|
||||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
patient_im_id?: string
|
||||||
|
patient_name?: string
|
||||||
|
sync_error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
|
export interface ImChatSyncProgress {
|
||||||
export function triggerImChatSync(data: { diagnosis_id: number }) {
|
sync_token?: string
|
||||||
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取企业微信聊天记录
|
// 获取企业微信聊天记录
|
||||||
@@ -429,7 +467,7 @@ export function prescriptionOrderEditTime(params: { id: number; create_time: str
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
/** 仅修改业务订单的承运商与快递单号;处方和支付单均审核通过后,所有履约状态均可使用 */
|
||||||
export function prescriptionOrderDdcode(params: {
|
export function prescriptionOrderDdcode(params: {
|
||||||
id: number
|
id: number
|
||||||
express_company: string
|
express_company: string
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ export function unbindWorkWechat() {
|
|||||||
export function changeFirstPassword(params: { password: string; password_confirm: string }) {
|
export function changeFirstPassword(params: { password: string; password_confirm: string }) {
|
||||||
return request.post({ url: '/login/changeFirstPassword', params })
|
return request.post({ url: '/login/changeFirstPassword', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一账号登录开关和服务器生成的固定登录入口
|
||||||
|
export function getIamConfig() {
|
||||||
|
return request.get({ url: '/iam/config' }, { withToken: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览器绑定的一次性兑换码;不重试,也不将旧业务 token 带入认证
|
||||||
|
export function iamLogin(ticket: string) {
|
||||||
|
return request.post(
|
||||||
|
{ url: '/iam/exchange', params: { ticket, terminal: config.terminal }, withCredentials: true },
|
||||||
|
{ withToken: false, isOpenRetry: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,18 +55,19 @@
|
|||||||
<ImagePicker />
|
<ImagePicker />
|
||||||
<FilePicker />
|
<FilePicker />
|
||||||
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
||||||
<AudioCallPicker v-if="isCallReady" />
|
<AudioCallPicker v-if="isCallReady && canAudioCall" />
|
||||||
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.RecordMode=2(混流/合流),见 TrtcCloudRecordingService -->
|
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.RecordMode=2(混流/合流),见 TrtcCloudRecordingService -->
|
||||||
<VideoCallPicker v-if="isCallReady" />
|
<VideoCallPicker v-if="isCallReady && canVideoCall" />
|
||||||
<!-- 群组视频通话(基于 TUICallKitServer.calls,多人通话入口) -->
|
<!-- 群组视频通话(基于 TUICallKitServer.calls,多人通话入口) -->
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isCallReady"
|
v-if="isCallReady && canVideoCall"
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="startGroupVideoCall"
|
@click="startGroupVideoCall"
|
||||||
>
|
>
|
||||||
群视频
|
群视频
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊,不支持音视频通话</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MessageInput>
|
</MessageInput>
|
||||||
@@ -138,12 +139,15 @@ import {
|
|||||||
bindCallRoom,
|
bindCallRoom,
|
||||||
endCall,
|
endCall,
|
||||||
getCallSignature,
|
getCallSignature,
|
||||||
|
triggerImChatSync,
|
||||||
startCall
|
startCall
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||||||
|
import { createImChatArchiveTrigger } from '@/utils/im-chat-archive-trigger'
|
||||||
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||||
|
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
|
||||||
import {
|
import {
|
||||||
formatTUICallUserError,
|
formatTUICallUserError,
|
||||||
getTUICallPackageArrearsMessage,
|
getTUICallPackageArrearsMessage,
|
||||||
@@ -185,7 +189,17 @@ const isReady = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
const appointmentType = ref<string | null | undefined>('video')
|
const appointmentType = ref<string | null | undefined>('video')
|
||||||
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
|
const appointmentId = ref(0)
|
||||||
|
const confirmedTypeLabel = ref('')
|
||||||
|
const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointmentTypeDescription(appointmentType.value))
|
||||||
|
const serverAllowsVideo = ref(false)
|
||||||
|
const serverAllowsAudio = ref(false)
|
||||||
|
const callDisabledReason = ref('正在确认挂号类型')
|
||||||
|
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
|
||||||
|
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
|
||||||
|
let releaseAppointmentCallGuard: (() => void) | undefined
|
||||||
|
let chatContextVersion = 0
|
||||||
|
let outgoingCallType = 2
|
||||||
const patientId = ref<number | null>(null)
|
const patientId = ref<number | null>(null)
|
||||||
const diagnosisId = ref<number | null>(null)
|
const diagnosisId = ref<number | null>(null)
|
||||||
const loadingText = ref('正在初始化...')
|
const loadingText = ref('正在初始化...')
|
||||||
@@ -197,6 +211,68 @@ const { login, logout } = useLoginState()
|
|||||||
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
||||||
const userStore = useUserStore()
|
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 一致) */
|
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
|
||||||
const CALL_STATUS_IDLE = 'idle'
|
const CALL_STATUS_IDLE = 'idle'
|
||||||
const CALL_STATUS_CALLING = 'calling'
|
const CALL_STATUS_CALLING = 'calling'
|
||||||
@@ -628,6 +704,7 @@ function installTUICallKitRoomHooks() {
|
|||||||
const original = server[methodName]
|
const original = server[methodName]
|
||||||
if (typeof original !== 'function') return
|
if (typeof original !== 'function') return
|
||||||
server[methodName] = async function patched(...args: unknown[]) {
|
server[methodName] = async function patched(...args: unknown[]) {
|
||||||
|
await checkAppointmentOutgoingCall(args[0])
|
||||||
const result = await original.apply(server, args)
|
const result = await original.apply(server, args)
|
||||||
await flushAndBindRoomAfterTUICallApi(methodName)
|
await flushAndBindRoomAfterTUICallApi(methodName)
|
||||||
return result
|
return result
|
||||||
@@ -695,7 +772,8 @@ async function ensureCallRecordStarted() {
|
|||||||
await startCall({
|
await startCall({
|
||||||
diagnosis_id: diagnosisId.value,
|
diagnosis_id: diagnosisId.value,
|
||||||
patient_id: patientId.value,
|
patient_id: patientId.value,
|
||||||
call_type: 2
|
appointment_id: appointmentId.value,
|
||||||
|
call_type: outgoingCallType
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[chat-dialog] startCall 记录失败', e)
|
console.warn('[chat-dialog] startCall 记录失败', e)
|
||||||
@@ -939,6 +1017,9 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopChatArchiveWatch(true)
|
||||||
|
chatContextVersion++
|
||||||
|
releaseAppointmentCallGuard?.()
|
||||||
clearLocalRecordingStartTimer()
|
clearLocalRecordingStartTimer()
|
||||||
localCallRecorder.reset()
|
localCallRecorder.reset()
|
||||||
tearDownCallRoomBinding?.()
|
tearDownCallRoomBinding?.()
|
||||||
@@ -1001,7 +1082,8 @@ watch(() => activeConversation.value, async (newConversation) => {
|
|||||||
try {
|
try {
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
|
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
|
||||||
diagnosis_id: diagnosisId.value
|
diagnosis_id: diagnosisId.value,
|
||||||
|
appointment_id: appointmentId.value
|
||||||
})
|
})
|
||||||
syncLochostVodFromSignature(res)
|
syncLochostVodFromSignature(res)
|
||||||
patientUserId.value = res.patientUserId
|
patientUserId.value = res.patientUserId
|
||||||
@@ -1016,7 +1098,11 @@ watch(() => activeConversation.value, async (newConversation) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
|
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
|
||||||
|
stopChatArchiveWatch(true)
|
||||||
|
const contextVersion = ++chatContextVersion
|
||||||
|
releaseAppointmentCallGuard?.()
|
||||||
|
releaseAppointmentCallGuard = registerAppointmentCallGuard(prepareAppointmentCall)
|
||||||
visible.value = true
|
visible.value = true
|
||||||
isMinimized.value = false
|
isMinimized.value = false
|
||||||
posX.value = 100
|
posX.value = 100
|
||||||
@@ -1024,6 +1110,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
|||||||
resetCallKitPositionToLeft()
|
resetCallKitPositionToLeft()
|
||||||
patientName.value = data.patientName
|
patientName.value = data.patientName
|
||||||
appointmentType.value = data.appointmentType
|
appointmentType.value = data.appointmentType
|
||||||
|
appointmentId.value = Number(data.appointmentId || 0)
|
||||||
|
confirmedTypeLabel.value = ''
|
||||||
|
serverAllowsVideo.value = false
|
||||||
|
serverAllowsAudio.value = false
|
||||||
|
callDisabledReason.value = '正在确认挂号类型'
|
||||||
patientId.value = data.patientId
|
patientId.value = data.patientId
|
||||||
diagnosisId.value = data.diagnosisId || null
|
diagnosisId.value = data.diagnosisId || null
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -1036,8 +1127,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
|||||||
// 获取医生的签名信息
|
// 获取医生的签名信息
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: data.patientId,
|
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)
|
syncLochostVodFromSignature(res)
|
||||||
assistant_ids.value = res.assistant_id
|
assistant_ids.value = res.assistant_id
|
||||||
console.log('后端返回的签名数据:', res)
|
console.log('后端返回的签名数据:', res)
|
||||||
@@ -1123,7 +1217,9 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
|||||||
|
|
||||||
// 等待 UIKit 初始化完成后创建并激活会话
|
// 等待 UIKit 初始化完成后创建并激活会话
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
if (contextVersion !== chatContextVersion || !visible.value) return
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
|
startChatArchiveWatch()
|
||||||
|
|
||||||
// 使用 nextTick 确保组件已渲染
|
// 使用 nextTick 确保组件已渲染
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -1164,6 +1260,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
|||||||
|
|
||||||
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
|
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
|
||||||
const startGroupVideoCall = async () => {
|
const startGroupVideoCall = async () => {
|
||||||
|
if (!canVideoCall.value) {
|
||||||
|
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isCallReady.value) {
|
if (!isCallReady.value) {
|
||||||
feedback.msgWarning('通话服务初始化中,请稍后再试')
|
feedback.msgWarning('通话服务初始化中,请稍后再试')
|
||||||
return
|
return
|
||||||
@@ -1180,7 +1280,8 @@ const startGroupVideoCall = async () => {
|
|||||||
console.warn('assistant_id 为空,尝试重新获取')
|
console.warn('assistant_id 为空,尝试重新获取')
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: patientId.value!,
|
patient_id: patientId.value!,
|
||||||
diagnosis_id: diagnosisId.value
|
diagnosis_id: diagnosisId.value,
|
||||||
|
appointment_id: appointmentId.value
|
||||||
})
|
})
|
||||||
syncLochostVodFromSignature(res)
|
syncLochostVodFromSignature(res)
|
||||||
assistant_ids.value = res.assistant_id
|
assistant_ids.value = res.assistant_id
|
||||||
@@ -1360,6 +1461,9 @@ const onHeaderMouseUp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
|
stopChatArchiveWatch(true)
|
||||||
|
chatContextVersion++
|
||||||
|
releaseAppointmentCallGuard?.()
|
||||||
unbindImHangupMessageListener()
|
unbindImHangupMessageListener()
|
||||||
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
|
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
|
||||||
if (isCallReady.value) {
|
if (isCallReady.value) {
|
||||||
@@ -1529,6 +1633,12 @@ defineExpose({ open })
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 消息工具栏样式 */
|
/* 消息工具栏样式 */
|
||||||
|
.text-consultation-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-toolbar {
|
.message-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
import { getUserInfo, login, logout, workWechatLogin } from '@/api/user'
|
import { getUserInfo, iamLogin, login, logout, workWechatLogin } from '@/api/user'
|
||||||
import { TOKEN_KEY } from '@/enums/cacheEnums'
|
import { TOKEN_KEY } from '@/enums/cacheEnums'
|
||||||
import { PageEnum } from '@/enums/pageEnum'
|
import { PageEnum } from '@/enums/pageEnum'
|
||||||
import router, { filterAsyncRoutes } from '@/router'
|
import router, { filterAsyncRoutes } from '@/router'
|
||||||
@@ -83,6 +83,13 @@ const useUserStore = defineStore({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async iamLogin(ticket: string) {
|
||||||
|
const data = await iamLogin(ticket)
|
||||||
|
this.token = data.token
|
||||||
|
this.isPaw = data.is_paw ?? 1
|
||||||
|
cache.set(TOKEN_KEY, data.token)
|
||||||
|
return data
|
||||||
|
},
|
||||||
getUserInfo() {
|
getUserInfo() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
type OutgoingCallGuard = (callType: number) => Promise<void>
|
||||||
|
|
||||||
|
// 通话 SDK 为全局单例;使用当前窗口的校验器,避免首个组件闭包保留旧患者类型。
|
||||||
|
let activeGuard: OutgoingCallGuard | undefined
|
||||||
|
|
||||||
|
export function registerAppointmentCallGuard(guard: OutgoingCallGuard): () => void {
|
||||||
|
activeGuard = guard
|
||||||
|
return () => {
|
||||||
|
if (activeGuard === guard) activeGuard = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAppointmentOutgoingCall(params: unknown): Promise<void> {
|
||||||
|
const guard = activeGuard
|
||||||
|
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
|
||||||
|
const type = params && typeof params === 'object' && 'type' in params ? Number(params.type) : 2
|
||||||
|
await guard(type === 1 ? 1 : 2)
|
||||||
|
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
|
||||||
|
}
|
||||||
@@ -6,3 +6,8 @@ export function appointmentTypeDescription(value?: string | null): string {
|
|||||||
if (value === 'phone') return '电话问诊'
|
if (value === 'phone') return '电话问诊'
|
||||||
return '未知'
|
return '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图文及未知问诊方式不提供视频;只有已存在挂号的历史空值沿用视频。 */
|
||||||
|
export function canAppointmentVideoCall(value?: string | null): boolean {
|
||||||
|
return value == null || value.trim() === '' || value === 'video'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** 合并聊天窗口的收发事件;每次同步只接受后端读取的云端消息,不上报客户端正文。 */
|
||||||
|
export function createImChatArchiveTrigger(
|
||||||
|
syncPage: (params: { diagnosis_id: number; scope: 'current'; sync_token?: string }) => Promise<{
|
||||||
|
sync_token?: string; completed: boolean; errors?: string[]
|
||||||
|
}>,
|
||||||
|
onError: (error: unknown) => void = () => {}
|
||||||
|
) {
|
||||||
|
const jobs = new Map<number, { again: boolean; promise: Promise<void> }>()
|
||||||
|
return (diagnosisId: number): Promise<void> => {
|
||||||
|
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) return Promise.resolve()
|
||||||
|
const pending = jobs.get(diagnosisId)
|
||||||
|
if (pending) {
|
||||||
|
pending.again = true
|
||||||
|
return pending.promise
|
||||||
|
}
|
||||||
|
const job = { again: false, promise: Promise.resolve() }
|
||||||
|
jobs.set(diagnosisId, job)
|
||||||
|
job.promise = (async () => {
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
job.again = false
|
||||||
|
let token: string | undefined
|
||||||
|
for (;;) {
|
||||||
|
const result = await syncPage({ diagnosis_id: diagnosisId, scope: 'current', sync_token: token })
|
||||||
|
if (result.completed) {
|
||||||
|
if (result.errors?.length) throw new Error(result.errors.join(';'))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!result.sync_token) throw new Error('聊天记录同步未返回进度')
|
||||||
|
token = result.sync_token
|
||||||
|
}
|
||||||
|
// 同步期间收到新消息时再追一次,覆盖新消息晚于本轮首页的情况。
|
||||||
|
} while (job.again)
|
||||||
|
} catch (error) {
|
||||||
|
onError(error)
|
||||||
|
} finally {
|
||||||
|
jobs.delete(diagnosisId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return job.promise
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm'
|
||||||
|
|
||||||
|
type NoticeKind = 'success' | 'warning' | 'error'
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
interface HistoryDependencies {
|
||||||
|
load: (diagnosisId: number) => Promise<ImChatMessagesResponse>
|
||||||
|
sync: (diagnosisId: number, token?: string) => Promise<ImChatSyncProgress>
|
||||||
|
notify?: (kind: NoticeKind, message: string) => void
|
||||||
|
now?: () => number
|
||||||
|
setTimer?: (callback: () => void, delay: number) => TimerHandle
|
||||||
|
clearTimer?: (timer: TimerHandle) => void
|
||||||
|
visible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
diagnosisId: number
|
||||||
|
generation: number
|
||||||
|
archiveRequest?: Promise<boolean>
|
||||||
|
syncRequest?: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each visible diagnosis owns its requests and one timer; late responses cannot update another patient. */
|
||||||
|
export function createImChatHistory(deps: HistoryDependencies) {
|
||||||
|
const state = reactive({
|
||||||
|
rows: [] as any[],
|
||||||
|
patientImId: '',
|
||||||
|
patientName: '',
|
||||||
|
loading: false,
|
||||||
|
syncing: false,
|
||||||
|
hasLoaded: false,
|
||||||
|
readError: '',
|
||||||
|
syncError: '',
|
||||||
|
partialErrors: [] as string[],
|
||||||
|
inserted: 0,
|
||||||
|
processedPeers: 0,
|
||||||
|
totalPeers: 0,
|
||||||
|
phase: '',
|
||||||
|
checkedAccounts: 0,
|
||||||
|
candidateAccounts: 0,
|
||||||
|
skippedAccounts: 0,
|
||||||
|
lastSyncAt: null as number | null
|
||||||
|
})
|
||||||
|
const now = deps.now ?? Date.now
|
||||||
|
const setTimer = deps.setTimer ?? setTimeout
|
||||||
|
const clearTimer = deps.clearTimer ?? clearTimeout
|
||||||
|
let diagnosisId = 0
|
||||||
|
let generation = 0
|
||||||
|
let visible = deps.visible ?? true
|
||||||
|
let disposed = false
|
||||||
|
let session: Session | undefined
|
||||||
|
let timer: TimerHandle | undefined
|
||||||
|
|
||||||
|
function current(target: Session) {
|
||||||
|
return !disposed && visible && target === session && target.generation === generation && target.diagnosisId === diagnosisId
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScheduledSync() {
|
||||||
|
if (timer !== undefined) clearTimer(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
generation++
|
||||||
|
clearScheduledSync()
|
||||||
|
session = undefined
|
||||||
|
state.loading = false
|
||||||
|
state.syncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArchived(target: Session, freshAfterPending = false): Promise<boolean> {
|
||||||
|
if (!current(target)) return false
|
||||||
|
if (target.archiveRequest) {
|
||||||
|
const result = await target.archiveRequest
|
||||||
|
if (!freshAfterPending || !current(target)) return result
|
||||||
|
return loadArchived(target)
|
||||||
|
}
|
||||||
|
state.loading = true
|
||||||
|
const request = Promise.resolve().then(async () => {
|
||||||
|
if (!current(target)) return false
|
||||||
|
try {
|
||||||
|
const response = await deps.load(target.diagnosisId)
|
||||||
|
if (!current(target)) return false
|
||||||
|
if (!Array.isArray(response?.lists)) throw new Error('聊天记录接口返回的数据不完整')
|
||||||
|
state.rows = response.lists
|
||||||
|
state.patientImId = response.patient_im_id || ''
|
||||||
|
state.patientName = response.patient_name || ''
|
||||||
|
state.hasLoaded = true
|
||||||
|
state.readError = ''
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (current(target)) state.readError = imChatErrorMessage(error, '读取聊天记录失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
target.archiveRequest = request
|
||||||
|
try {
|
||||||
|
return await request
|
||||||
|
} finally {
|
||||||
|
if (target.archiveRequest === request) target.archiveRequest = undefined
|
||||||
|
if (current(target)) state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync(target: Session) {
|
||||||
|
clearScheduledSync()
|
||||||
|
if (!current(target)) return
|
||||||
|
timer = setTimer(() => {
|
||||||
|
timer = undefined
|
||||||
|
if (current(target)) void sync(target)
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sync(target: Session, manual = false): Promise<void> {
|
||||||
|
if (!current(target)) return
|
||||||
|
if (target.syncRequest) return target.syncRequest
|
||||||
|
clearScheduledSync()
|
||||||
|
state.syncing = true
|
||||||
|
state.syncError = ''
|
||||||
|
state.partialErrors = []
|
||||||
|
state.inserted = 0
|
||||||
|
state.processedPeers = 0
|
||||||
|
state.totalPeers = 0
|
||||||
|
state.phase = 'checking_accounts'
|
||||||
|
state.checkedAccounts = 0
|
||||||
|
state.candidateAccounts = 0
|
||||||
|
state.skippedAccounts = 0
|
||||||
|
const request = Promise.resolve().then(async () => {
|
||||||
|
let token: string | undefined
|
||||||
|
let pagesSinceRefresh = 0
|
||||||
|
let lastRefreshAt = now()
|
||||||
|
const errors = new Set<string>()
|
||||||
|
try {
|
||||||
|
while (current(target)) {
|
||||||
|
const progress = await deps.sync(target.diagnosisId, token)
|
||||||
|
if (!current(target)) return
|
||||||
|
if (typeof progress?.completed !== 'boolean') throw new Error('同步接口未返回有效的完成状态')
|
||||||
|
state.inserted = Number(progress.inserted) || 0
|
||||||
|
state.processedPeers = Number(progress.processed_peers) || 0
|
||||||
|
state.totalPeers = Number(progress.total_peers) || 0
|
||||||
|
state.phase = progress.phase || 'syncing'
|
||||||
|
state.checkedAccounts = Number(progress.checked_accounts) || 0
|
||||||
|
state.candidateAccounts = Number(progress.candidate_accounts) || 0
|
||||||
|
state.skippedAccounts = Number(progress.skipped_accounts) || 0
|
||||||
|
for (const error of [progress.error, ...(progress.errors || [])]) {
|
||||||
|
if (typeof error === 'string' && error.trim()) errors.add(error)
|
||||||
|
}
|
||||||
|
state.partialErrors = [...errors]
|
||||||
|
if (progress.completed) {
|
||||||
|
const refreshed = await loadArchived(target, true)
|
||||||
|
if (!current(target)) return
|
||||||
|
state.lastSyncAt = now()
|
||||||
|
if (manual) {
|
||||||
|
if (errors.size) deps.notify?.('warning', `同步完成,部分会话失败:${[...errors].join(';')}`)
|
||||||
|
else if (!refreshed) deps.notify?.('warning', `同步已完成,但读取聊天记录失败:${state.readError}`)
|
||||||
|
else deps.notify?.('success', `聊天记录已更新,本次新增 ${state.inserted} 条`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!progress.sync_token) throw new Error('同步接口未返回继续同步所需的进度标识')
|
||||||
|
token = progress.sync_token
|
||||||
|
pagesSinceRefresh++
|
||||||
|
if (pagesSinceRefresh >= 3 || now() - lastRefreshAt >= 2000) {
|
||||||
|
await loadArchived(target, true)
|
||||||
|
pagesSinceRefresh = 0
|
||||||
|
lastRefreshAt = now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!current(target)) return
|
||||||
|
state.syncError = imChatErrorMessage(error, '同步聊天记录失败')
|
||||||
|
if (manual) deps.notify?.('error', state.syncError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
target.syncRequest = request
|
||||||
|
try {
|
||||||
|
await request
|
||||||
|
} finally {
|
||||||
|
if (target.syncRequest === request) target.syncRequest = undefined
|
||||||
|
if (current(target)) {
|
||||||
|
state.syncing = false
|
||||||
|
scheduleSync(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (disposed || !visible || !diagnosisId) return
|
||||||
|
const target: Session = { diagnosisId, generation }
|
||||||
|
session = target
|
||||||
|
void loadArchived(target)
|
||||||
|
void sync(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDiagnosis(value: number) {
|
||||||
|
const nextId = Number(value) > 0 ? Number(value) : 0
|
||||||
|
if (disposed || (nextId === diagnosisId && session)) return
|
||||||
|
invalidate()
|
||||||
|
diagnosisId = nextId
|
||||||
|
state.rows = []
|
||||||
|
state.patientImId = ''
|
||||||
|
state.patientName = ''
|
||||||
|
state.hasLoaded = false
|
||||||
|
state.readError = ''
|
||||||
|
state.syncError = ''
|
||||||
|
state.partialErrors = []
|
||||||
|
state.inserted = 0
|
||||||
|
state.processedPeers = 0
|
||||||
|
state.totalPeers = 0
|
||||||
|
state.phase = ''
|
||||||
|
state.checkedAccounts = 0
|
||||||
|
state.candidateAccounts = 0
|
||||||
|
state.skippedAccounts = 0
|
||||||
|
state.lastSyncAt = null
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisible(value: boolean) {
|
||||||
|
if (disposed || value === visible) return
|
||||||
|
visible = value
|
||||||
|
invalidate()
|
||||||
|
if (visible) start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
disposed = true
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
setDiagnosis,
|
||||||
|
setVisible,
|
||||||
|
dispose,
|
||||||
|
reload: () => session ? loadArchived(session) : Promise.resolve(false),
|
||||||
|
sync: () => session ? sync(session, true) : Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imChatErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (typeof error === 'string' && error.trim()) return error
|
||||||
|
const detail = error as any
|
||||||
|
return detail?.response?.data?.msg || detail?.msg || detail?.error || detail?.message || fallback
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
|
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
|
||||||
|
|
||||||
<!-- 企业微信自动授权中 -->
|
<!-- 企业微信自动授权中 -->
|
||||||
<div v-if="wxWorkAutoLogin" class="text-center py-10">
|
<div v-if="wxWorkAutoLogin || iamLoading" class="text-center py-10">
|
||||||
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
|
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
|
||||||
<Loading />
|
<Loading />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="text-gray-500">企业微信授权登录中...</div>
|
<div class="text-gray-500">{{ iamLoading ? '统一账号登录中...' : '企业微信授权登录中...' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -78,6 +78,20 @@
|
|||||||
请使用企业微信扫描二维码登录
|
请使用企业微信扫描二维码登录
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<section v-if="iamEnabled" class="iam-login-alternative" aria-label="其他登录方式">
|
||||||
|
<div class="iam-login-divider" aria-hidden="true">其他登录方式</div>
|
||||||
|
<el-button class="iam-login-entry" size="large" @click="handleIamLogin">
|
||||||
|
<span class="iam-login-entry__content">
|
||||||
|
<icon name="local-icon-anquan" size="22" />
|
||||||
|
<span>统一账号快捷登录</span>
|
||||||
|
</span>
|
||||||
|
<span class="iam-login-entry__arrow" aria-hidden="true">
|
||||||
|
<icon name="el-icon-ArrowRight" size="16" />
|
||||||
|
</span>
|
||||||
|
</el-button>
|
||||||
|
<p class="iam-login-hint">使用统一身份平台账号登录</p>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +113,7 @@ import LayoutFooter from '@/layout/components/footer.vue'
|
|||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import cache from '@/utils/cache'
|
import cache from '@/utils/cache'
|
||||||
import { getWorkWechatConfig } from '@/api/user'
|
import { getIamConfig, getWorkWechatConfig } from '@/api/user'
|
||||||
|
|
||||||
const passwordRef = shallowRef<InputInstance>()
|
const passwordRef = shallowRef<InputInstance>()
|
||||||
const formRef = shallowRef<FormInstance>()
|
const formRef = shallowRef<FormInstance>()
|
||||||
@@ -118,6 +132,66 @@ const rules = {
|
|||||||
password: [{ required: true, message: '请输入密码', trigger: ['blur'] }]
|
password: [{ required: true, message: '请输入密码', trigger: ['blur'] }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一账号登录是可选入口,不取代账号密码或企业微信登录。
|
||||||
|
const iamEnabled = ref(false)
|
||||||
|
const iamLoginUrl = ref('')
|
||||||
|
const iamLoading = ref(false)
|
||||||
|
let iamCallbackHandled = false
|
||||||
|
|
||||||
|
const loadIamConfig = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getIamConfig()
|
||||||
|
const url = new URL(result?.loginUrl || '', window.location.origin)
|
||||||
|
if (result?.enabled === true && url.protocol === 'https:') {
|
||||||
|
iamLoginUrl.value = url.href
|
||||||
|
iamEnabled.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IAM 不可用时,原有登录入口保持可用。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIamLogin = () => {
|
||||||
|
if (iamEnabled.value && iamLoginUrl.value) {
|
||||||
|
window.location.assign(iamLoginUrl.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIamCallback = async (ticket: string | null, error: string | null) => {
|
||||||
|
if (iamCallbackHandled) return
|
||||||
|
iamCallbackHandled = true
|
||||||
|
// 在任何 await 和兑换前移除票据,刷新不会重复兑换;保留其他 query/hash。
|
||||||
|
const cleanUrl = new URL(window.location.href)
|
||||||
|
cleanUrl.searchParams.delete('iam_ticket')
|
||||||
|
cleanUrl.searchParams.delete('iam_error')
|
||||||
|
// 同一回调不得随后被识别为企业微信授权。
|
||||||
|
cleanUrl.searchParams.delete('code')
|
||||||
|
cleanUrl.searchParams.delete('state')
|
||||||
|
window.history.replaceState(window.history.state, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash)
|
||||||
|
if (error || !ticket) {
|
||||||
|
ElMessage.error(error || '统一账号登录凭证无效,请重新登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iamLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await userStore.iamLogin(ticket)
|
||||||
|
if (result.is_paw === 0) {
|
||||||
|
await router.push('/change-password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.need_bind_work_wechat) {
|
||||||
|
await router.push('/bind-work-wechat')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAfterLogin()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.msg || error?.message || '统一账号登录失败,请重新登录或使用账号密码')
|
||||||
|
loginMode.value = 'account'
|
||||||
|
} finally {
|
||||||
|
iamLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 企业微信相关
|
// 企业微信相关
|
||||||
const loginMode = ref<'account' | 'wxwork'>('account')
|
const loginMode = ref<'account' | 'wxwork'>('account')
|
||||||
const wxWorkEnabled = ref(false)
|
const wxWorkEnabled = ref(false)
|
||||||
@@ -243,7 +317,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 检查 URL 中是否有企业微信回调 code
|
// 检查 URL 中是否有企业微信回调 code
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const wxCode = urlParams.get('code')
|
void loadIamConfig()
|
||||||
|
const hasIamCallback = urlParams.has('iam_ticket') || urlParams.has('iam_error')
|
||||||
|
if (hasIamCallback) {
|
||||||
|
await handleIamCallback(urlParams.get('iam_ticket'), urlParams.get('iam_error'))
|
||||||
|
}
|
||||||
|
const wxCode = hasIamCallback ? null : urlParams.get('code')
|
||||||
const wxState = urlParams.get('state')
|
const wxState = urlParams.get('state')
|
||||||
|
|
||||||
if (wxCode && wxState === 'admin_login') {
|
if (wxCode && wxState === 'admin_login') {
|
||||||
@@ -263,7 +342,7 @@ onMounted(async () => {
|
|||||||
wxWorkConfig.value = { corp_id: res.corp_id, agent_id: res.agent_id }
|
wxWorkConfig.value = { corp_id: res.corp_id, agent_id: res.agent_id }
|
||||||
|
|
||||||
// 在企业微信内:自动跳转 OAuth 授权
|
// 在企业微信内:自动跳转 OAuth 授权
|
||||||
if (isInWxWork()) {
|
if (isInWxWork() && !hasIamCallback) {
|
||||||
const redirectUri = encodeURIComponent(getRedirectUri())
|
const redirectUri = encodeURIComponent(getRedirectUri())
|
||||||
const authUrl =
|
const authUrl =
|
||||||
`https://open.weixin.qq.com/connect/oauth2/authorize` +
|
`https://open.weixin.qq.com/connect/oauth2/authorize` +
|
||||||
@@ -286,6 +365,82 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.iam-login-alternative {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-login-entry.el-button {
|
||||||
|
--el-button-bg-color: var(--el-color-primary-light-9);
|
||||||
|
--el-button-text-color: var(--el-color-primary);
|
||||||
|
--el-button-border-color: var(--el-color-primary-light-5);
|
||||||
|
--el-button-hover-bg-color: var(--el-color-primary-light-8);
|
||||||
|
--el-button-hover-text-color: var(--el-color-primary);
|
||||||
|
--el-button-hover-border-color: var(--el-color-primary);
|
||||||
|
--el-button-active-bg-color: var(--el-color-primary-light-8);
|
||||||
|
--el-button-active-border-color: var(--el-color-primary);
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background-color 150ms ease, border-color 150ms ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-login-entry__content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-login-entry__arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-login-hint {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.iam-login-entry.el-button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
background-image: url('./images/login_bg.png');
|
background-image: url('./images/login_bg.png');
|
||||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||||
|
|||||||
@@ -42,6 +42,13 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form-item label="状态">
|
||||||
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
||||||
<el-option label="已预约" :value="1" />
|
<el-option label="已预约" :value="1" />
|
||||||
@@ -115,7 +122,9 @@
|
|||||||
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
||||||
@@ -178,7 +187,7 @@
|
|||||||
<el-radio-button label="all">全天</el-radio-button>
|
<el-radio-button label="all">全天</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="问诊类型" required>
|
<el-form-item label="挂号类型" required>
|
||||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||||
<el-option label="视频问诊" value="video" />
|
<el-option label="视频问诊" value="video" />
|
||||||
<el-option label="图文问诊" value="text" />
|
<el-option label="图文问诊" value="text" />
|
||||||
@@ -292,6 +301,7 @@
|
|||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
||||||
|
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||||
import { getAssistants } from '@/api/tcm'
|
import { getAssistants } from '@/api/tcm'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
@@ -302,6 +312,7 @@ const queryParams = reactive({
|
|||||||
patient_name: '',
|
patient_name: '',
|
||||||
doctor_name: '',
|
doctor_name: '',
|
||||||
assistant_id: undefined as number | undefined,
|
assistant_id: undefined as number | undefined,
|
||||||
|
appointment_type: '',
|
||||||
status: '' as number | '',
|
status: '' as number | '',
|
||||||
channel_source: '' as string
|
channel_source: '' as string
|
||||||
})
|
})
|
||||||
@@ -312,6 +323,7 @@ const queryInit = {
|
|||||||
patient_name: '',
|
patient_name: '',
|
||||||
doctor_name: '',
|
doctor_name: '',
|
||||||
assistant_id: undefined as number | undefined,
|
assistant_id: undefined as number | undefined,
|
||||||
|
appointment_type: '',
|
||||||
status: '' as number | '',
|
status: '' as number | '',
|
||||||
channel_source: ''
|
channel_source: ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -871,7 +871,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -1067,7 +1076,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -2975,9 +2993,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShipRow(row: { fulfillment_status?: number }) {
|
function canShipRow(row: {
|
||||||
// 履约中(2) 可发货
|
fulfillment_status?: number
|
||||||
return Number(row.fulfillment_status) === 2
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
// 履约中(2) 且处方、支付单均审核通过才可发货
|
||||||
|
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShipMode = 'gancao' | 'direct'
|
type ShipMode = 'gancao' | 'direct'
|
||||||
@@ -3073,8 +3095,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
|||||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
function canQuickTrackRow(row: {
|
||||||
return true
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
return isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadPharmacyRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
@@ -3627,7 +3652,6 @@ async function submitEdit() {
|
|||||||
service_channel: editForm.service_channel || '',
|
service_channel: editForm.service_channel || '',
|
||||||
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
||||||
express_company: editForm.express_company || 'auto',
|
express_company: editForm.express_company || 'auto',
|
||||||
tracking_number: editForm.tracking_number || '',
|
|
||||||
fee_type: editForm.fee_type,
|
fee_type: editForm.fee_type,
|
||||||
amount: editForm.amount,
|
amount: editForm.amount,
|
||||||
remark_extra: editForm.remark_extra || '',
|
remark_extra: editForm.remark_extra || '',
|
||||||
@@ -3915,7 +3939,17 @@ const quickTrackForm = reactive({
|
|||||||
tracking_number: ''
|
tracking_number: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) {
|
function openQuickTrack(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canQuickTrackRow(row)) {
|
||||||
|
feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号')
|
||||||
|
return
|
||||||
|
}
|
||||||
quickTrackRowId.value = row.id
|
quickTrackRowId.value = row.id
|
||||||
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
||||||
@@ -3963,7 +3997,19 @@ function resolveShipModeForRow(row: { id: number; ship_mode?: unknown }) {
|
|||||||
return normalizeShipMode(row.ship_mode)
|
return normalizeShipMode(row.ship_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
function openShip(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
ship_mode?: unknown
|
||||||
|
fulfillment_status?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canShipRow(row)) {
|
||||||
|
feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货')
|
||||||
|
return
|
||||||
|
}
|
||||||
shipRowId.value = Number(row.id)
|
shipRowId.value = Number(row.id)
|
||||||
shipForm.ship_mode = resolveShipModeForRow(row)
|
shipForm.ship_mode = resolveShipModeForRow(row)
|
||||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
|
|||||||
@@ -1624,7 +1624,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -3422,9 +3431,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShipRow(row: { fulfillment_status?: number }) {
|
function canShipRow(row: {
|
||||||
// 履约中(2) 可发货
|
fulfillment_status?: number
|
||||||
return Number(row.fulfillment_status) === 2
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
// 履约中(2) 且处方、支付单均审核通过才可发货
|
||||||
|
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
||||||
@@ -3444,8 +3457,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
|||||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
function canQuickTrackRow(row: {
|
||||||
return true
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
return isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadPharmacyRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
@@ -4540,7 +4556,6 @@ async function submitEdit() {
|
|||||||
service_channel: editForm.service_channel || '',
|
service_channel: editForm.service_channel || '',
|
||||||
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
||||||
express_company: editForm.express_company || 'auto',
|
express_company: editForm.express_company || 'auto',
|
||||||
tracking_number: editForm.tracking_number || '',
|
|
||||||
fee_type: editForm.fee_type,
|
fee_type: editForm.fee_type,
|
||||||
amount: editForm.amount,
|
amount: editForm.amount,
|
||||||
remark_extra: editForm.remark_extra || '',
|
remark_extra: editForm.remark_extra || '',
|
||||||
@@ -4788,7 +4803,17 @@ const quickTrackForm = reactive({
|
|||||||
tracking_number: ''
|
tracking_number: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) {
|
function openQuickTrack(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canQuickTrackRow(row)) {
|
||||||
|
feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号')
|
||||||
|
return
|
||||||
|
}
|
||||||
quickTrackRowId.value = row.id
|
quickTrackRowId.value = row.id
|
||||||
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
||||||
@@ -4840,7 +4865,19 @@ const shipDialogModeDisplay = computed(() =>
|
|||||||
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||||
)
|
)
|
||||||
|
|
||||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
function openShip(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
ship_mode?: unknown
|
||||||
|
fulfillment_status?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canShipRow(row)) {
|
||||||
|
feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货')
|
||||||
|
return
|
||||||
|
}
|
||||||
shipRowId.value = row.id
|
shipRowId.value = row.id
|
||||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
||||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
|
|||||||
@@ -125,6 +125,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<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">
|
<el-table-column label="预约时间" min-width="165">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
||||||
@@ -325,6 +330,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|||||||
@@ -451,12 +451,14 @@ const handleCall = async (row: QueueRow) => {
|
|||||||
try {
|
try {
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: sourcePatientId,
|
patient_id: sourcePatientId,
|
||||||
|
appointment_id: Number(row.id),
|
||||||
diagnosis_id: diagnosisId
|
diagnosis_id: diagnosisId
|
||||||
})
|
})
|
||||||
chatDialogRef.value?.open({
|
chatDialogRef.value?.open({
|
||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentId: Number(row.id),
|
||||||
appointmentType: row.appointment_type,
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -163,7 +163,9 @@
|
|||||||
</el-table-column>
|
</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">
|
<el-table-column label="确认诊单" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -220,6 +222,7 @@
|
|||||||
|
|
||||||
<template v-if="row.status !== 3">
|
<template v-if="row.status !== 3">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canAppointmentVideoCall(row.appointment_type)"
|
||||||
v-perms="['tcm.diagnosis/videoQr']"
|
v-perms="['tcm.diagnosis/videoQr']"
|
||||||
type="warning"
|
type="warning"
|
||||||
link
|
link
|
||||||
@@ -237,7 +240,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
@click="handleChat(row)"
|
@click="handleChat(row)"
|
||||||
>
|
>
|
||||||
通话
|
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-perms="['doctor.appointment/complete']"
|
v-perms="['doctor.appointment/complete']"
|
||||||
@@ -340,8 +343,8 @@
|
|||||||
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
|
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">预约类型</span>
|
<span class="detail-label">面诊类型</span>
|
||||||
<span class="detail-value">{{ detailData.appointment_type_desc }}</span>
|
<span class="detail-value">{{ appointmentTypeDescription(detailData.appointment_type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,6 +559,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||||
@@ -1012,7 +1016,8 @@ const handleChat = async (row: any) => {
|
|||||||
// 获取聊天签名信息
|
// 获取聊天签名信息
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: sourcePatientId,
|
patient_id: sourcePatientId,
|
||||||
diagnosis_id: diagnosisId
|
diagnosis_id: diagnosisId,
|
||||||
|
appointment_id: Number(row.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('获取聊天签名成功:', res)
|
console.log('获取聊天签名成功:', res)
|
||||||
@@ -1022,6 +1027,7 @@ const handleChat = async (row: any) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentId: Number(row.id),
|
||||||
appointmentType: row.appointment_type,
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res // 传入签名数据
|
signatureData: res // 传入签名数据
|
||||||
})
|
})
|
||||||
@@ -1033,6 +1039,10 @@ const handleChat = async (row: any) => {
|
|||||||
|
|
||||||
// 生成小程序二维码(跳转小程序路径:pages/login/login)
|
// 生成小程序二维码(跳转小程序路径: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) {
|
if (!row.patient_id) {
|
||||||
feedback.msgWarning('患者信息不完整')
|
feedback.msgWarning('患者信息不完整')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -108,6 +108,10 @@
|
|||||||
<span class="apt-h5-row-key">预约</span>
|
<span class="apt-h5-row-key">预约</span>
|
||||||
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
|
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div class="apt-h5-row">
|
||||||
<span class="apt-h5-row-key">医生</span>
|
<span class="apt-h5-row-key">医生</span>
|
||||||
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
|
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
|
||||||
@@ -147,7 +151,7 @@
|
|||||||
:icon="Phone"
|
:icon="Phone"
|
||||||
@click="handleChat(row)"
|
@click="handleChat(row)"
|
||||||
>
|
>
|
||||||
通话
|
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
|
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
|
||||||
@@ -179,7 +183,7 @@
|
|||||||
>
|
>
|
||||||
<div v-if="moreSheetRow" class="apt-h5-actions">
|
<div v-if="moreSheetRow" class="apt-h5-actions">
|
||||||
<button
|
<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"
|
class="apt-h5-action"
|
||||||
@click="runAction('videoQr')"
|
@click="runAction('videoQr')"
|
||||||
>
|
>
|
||||||
@@ -236,7 +240,7 @@
|
|||||||
<div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div>
|
<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.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div>
|
||||||
<div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -336,7 +340,7 @@
|
|||||||
plain
|
plain
|
||||||
@click="handleChat(detailActionRow)"
|
@click="handleChat(detailActionRow)"
|
||||||
>
|
>
|
||||||
通话
|
{{ canAppointmentVideoCall(detailActionRow.appointment_type) ? '通话' : '图文沟通' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
|
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
|
||||||
@@ -408,6 +412,7 @@
|
|||||||
</template>
|
</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 { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
|
||||||
import { getWeappConfig } from '@/api/channel/weapp'
|
import { getWeappConfig } from '@/api/channel/weapp'
|
||||||
import {
|
import {
|
||||||
@@ -656,12 +661,14 @@ const handleChat = async (row: any) => {
|
|||||||
try {
|
try {
|
||||||
const res = await getCallSignature({
|
const res = await getCallSignature({
|
||||||
patient_id: sourcePatientId,
|
patient_id: sourcePatientId,
|
||||||
diagnosis_id: diagnosisId
|
diagnosis_id: diagnosisId,
|
||||||
|
appointment_id: Number(row.id)
|
||||||
})
|
})
|
||||||
chatDialogRef.value?.open({
|
chatDialogRef.value?.open({
|
||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentId: Number(row.id),
|
||||||
appointmentType: row.appointment_type,
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
@@ -778,6 +785,10 @@ const qrcodeLoading = ref(false)
|
|||||||
const qrcodeUrl = ref('')
|
const qrcodeUrl = ref('')
|
||||||
const currentQRCodeRow = ref<any>(null)
|
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) {
|
if (!row.patient_id) {
|
||||||
feedback.msgWarning('患者信息不完整')
|
feedback.msgWarning('患者信息不完整')
|
||||||
return
|
return
|
||||||
@@ -1185,7 +1196,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apt-h5-row-key {
|
.apt-h5-row-key {
|
||||||
flex: 0 0 38px;
|
flex: 0 0 60px;
|
||||||
|
white-space: nowrap;
|
||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 预约类型 -->
|
<!-- 挂号类型 -->
|
||||||
<el-form-item label="预约类型:">
|
<el-form-item label="挂号类型:">
|
||||||
<el-radio-group v-model="form.appointmentType">
|
<el-radio-group v-model="form.appointmentType">
|
||||||
<el-radio value="video">视频问诊</el-radio>
|
<el-radio value="video">视频问诊</el-radio>
|
||||||
<el-radio value="text">图文问诊</el-radio>
|
<el-radio value="text">图文问诊</el-radio>
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
<div class="im-chat-record-panel">
|
<div class="im-chat-record-panel">
|
||||||
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
|
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
|
||||||
<p class="panel-tip">
|
<p class="panel-tip">
|
||||||
展示单聊记录:已合并患者 与
|
展示患者所有诊单中与
|
||||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||||
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。
|
打开页面后自动加载并同步最新消息,页面可见期间每 30 秒继续检查更新。
|
||||||
</p>
|
</p>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<div class="toolbar mb-3">
|
<div class="toolbar mb-3">
|
||||||
<el-button type="primary" link :loading="syncing" @click="triggerSync">
|
<el-button type="primary" link :loading="syncing" @click="triggerSync">
|
||||||
<el-icon class="mr-1"><Promotion /></el-icon>
|
<el-icon class="mr-1"><Promotion /></el-icon>
|
||||||
同步最新(后台异步)
|
同步最新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" link :loading="loading" @click="reloadArchived">
|
<el-button type="primary" link :loading="loading" @click="reloadArchived">
|
||||||
<el-icon class="mr-1"><Refresh /></el-icon>
|
<el-icon class="mr-1"><Refresh /></el-icon>
|
||||||
@@ -19,12 +19,30 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="chat-wrap">
|
<p v-if="syncing" class="sync-status" role="status" aria-live="polite">
|
||||||
<template v-if="!loading && rows.length">
|
<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 class="chat-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in rows"
|
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="chat-row"
|
||||||
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
|
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
|
||||||
>
|
>
|
||||||
@@ -64,31 +82,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
|
<el-empty v-else-if="!loading" :description="emptyDescription" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 dayjs from 'dayjs'
|
||||||
import { Refresh, Promotion } from '@element-plus/icons-vue'
|
import { Refresh, Promotion } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
|
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
|
||||||
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
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<{
|
const props = defineProps<{
|
||||||
diagnosisId: number
|
diagnosisId: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const documentVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
||||||
const syncing = ref(false)
|
const controller = createImChatHistory({
|
||||||
const rawRows = shallowRef<any[]>([])
|
load: (diagnosisId) => getImChatMessages({ diagnosis_id: diagnosisId, only_archived: 1 }),
|
||||||
const patientImId = ref('')
|
sync: (diagnosisId, syncToken) => triggerImChatSync({ diagnosis_id: diagnosisId, ...(syncToken ? { sync_token: syncToken } : {}) }),
|
||||||
const patientName = ref('')
|
notify: (kind, message) => ElMessage[kind](message),
|
||||||
|
visible: documentVisible()
|
||||||
const patientImHint = computed(() => patientImId.value || 'patient_*')
|
})
|
||||||
|
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 {
|
interface EnrichedRow {
|
||||||
raw: any
|
raw: any
|
||||||
@@ -109,7 +136,11 @@ const MSG_TYPE_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = computed<EnrichedRow[]>(() =>
|
const rows = computed<EnrichedRow[]>(() =>
|
||||||
rawRows.value.map((row) => {
|
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)
|
const fr = parseImFriendly(row)
|
||||||
let tag = ''
|
let tag = ''
|
||||||
if (fr?.tag) {
|
if (fr?.tag) {
|
||||||
@@ -119,6 +150,7 @@ const rows = computed<EnrichedRow[]>(() =>
|
|||||||
}
|
}
|
||||||
return { raw: row, friendly: fr, tag }
|
return { raw: row, friendly: fr, tag }
|
||||||
})
|
})
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
function formatTime(ts: number | undefined) {
|
function formatTime(ts: number | undefined) {
|
||||||
@@ -145,55 +177,38 @@ function senderLabel(row: any) {
|
|||||||
return patientName.value ? `患者(${patientName.value})` : '患者'
|
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() {
|
function reloadArchived() {
|
||||||
load()
|
return controller.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerSync() {
|
function triggerSync() {
|
||||||
if (!props.diagnosisId) return
|
return controller.sync()
|
||||||
syncing.value = true
|
|
||||||
try {
|
|
||||||
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
|
|
||||||
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
ElMessage.error('发起同步失败')
|
|
||||||
} finally {
|
|
||||||
syncing.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.diagnosisId,
|
() => props.diagnosisId,
|
||||||
() => {
|
(diagnosisId) => controller.setDiagnosis(diagnosisId),
|
||||||
load()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
{ 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 })
|
defineExpose({ refresh: reloadArchived })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -212,6 +227,12 @@ defineExpose({ refresh: reloadArchived })
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
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 {
|
.chat-wrap {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
|
|||||||
@@ -259,6 +259,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<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">
|
<el-table-column label="确认" width="88" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
|
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
|
||||||
@@ -388,7 +401,7 @@
|
|||||||
<el-icon><Remove /></el-icon>取消指派
|
<el-icon><Remove /></el-icon>取消指派
|
||||||
</el-dropdown-item>
|
</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="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
|
<el-dropdown-item
|
||||||
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
|
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||||
command="cancelApt"
|
command="cancelApt"
|
||||||
@@ -732,6 +745,7 @@ import { getDictData } from '@/api/app'
|
|||||||
import { getWeappConfig } from '@/api/channel/weapp'
|
import { getWeappConfig } from '@/api/channel/weapp'
|
||||||
import feedback from '@/utils/feedback'
|
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 { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -1378,9 +1392,10 @@ type VideoCallHint = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canWatchCallEntry = (row: any) =>
|
const canWatchCallEntry = (row: any) =>
|
||||||
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
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 watchCallLiveTooltip = (row: any) => {
|
||||||
const h = row.video_call_hint as VideoCallHint | undefined
|
const h = row.video_call_hint as VideoCallHint | undefined
|
||||||
@@ -1405,6 +1420,10 @@ const watchCallEnterTooltip = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWatchCallEntryClick = (row: any) => {
|
const onWatchCallEntryClick = (row: any) => {
|
||||||
|
if (!canWatchCallEntry(row)) {
|
||||||
|
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (watchCallState(row) !== 'live') {
|
if (watchCallState(row) !== 'live') {
|
||||||
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
|
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
|
||||||
return
|
return
|
||||||
@@ -1716,7 +1735,7 @@ const handleRowAction = (cmd: string, row: any) => {
|
|||||||
case 'cancelAssign': handleCancelAssign(row); break
|
case 'cancelAssign': handleCancelAssign(row); break
|
||||||
case 'videoQr':
|
case 'videoQr':
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
if (!isAppointmentActiveForVideo(row)) {
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleVideoQRCode(row)
|
handleVideoQRCode(row)
|
||||||
@@ -1753,7 +1772,8 @@ const appointmentRows = (row: any) => {
|
|||||||
status: row.appointment_status,
|
status: row.appointment_status,
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: row.appointment_doctor_id,
|
||||||
doctor_name: row.appointment_doctor_name,
|
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_* 仍可能指向旧记录。
|
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||||
*/
|
*/
|
||||||
const activeAppointment = (row: any) =>
|
const activeAppointment = (row: any, videoOnly = false) =>
|
||||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
appointmentRows(row).find((apt: any) =>
|
||||||
|
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
|
||||||
|
) ?? null
|
||||||
|
|
||||||
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||||
const activeAppointmentRow = (row: any) => {
|
const activeAppointmentRow = (row: any, videoOnly = false) => {
|
||||||
const apt = activeAppointment(row)
|
const apt = activeAppointment(row, videoOnly)
|
||||||
if (!apt) return null
|
if (!apt) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1794,12 +1816,14 @@ const activeAppointmentRow = (row: any) => {
|
|||||||
appointment_status: apt.status,
|
appointment_status: apt.status,
|
||||||
appointment_doctor_id: apt.doctor_id,
|
appointment_doctor_id: apt.doctor_id,
|
||||||
appointment_doctor_name: apt.doctor_name,
|
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) => {
|
const canCancelAppointmentRow = (row: any) => {
|
||||||
@@ -1983,11 +2007,11 @@ const submitFillIdCard = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
// 生成视频二维码(跳转登录页)- 仅已预约的视频问诊可生成
|
||||||
const handleVideoQRCode = async (row: any) => {
|
const handleVideoQRCode = async (row: any) => {
|
||||||
const qrcodeRow = activeAppointmentRow(row)
|
const qrcodeRow = activeAppointmentRow(row, true)
|
||||||
if (!qrcodeRow) {
|
if (!qrcodeRow) {
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!qrcodeRow.patient_id) {
|
if (!qrcodeRow.patient_id) {
|
||||||
|
|||||||
@@ -156,12 +156,14 @@
|
|||||||
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
|
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
|
||||||
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
|
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
|
||||||
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</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>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="dh5-apt-none">未挂号</span>
|
<span v-else class="dh5-apt-none">未挂号</span>
|
||||||
@@ -299,7 +301,7 @@
|
|||||||
<span>视频二维码</span>
|
<span>视频二维码</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
|
v-if="hasActiveAppointment(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||||
class="dh5-action"
|
class="dh5-action"
|
||||||
@click="runAction('confirmQr')"
|
@click="runAction('confirmQr')"
|
||||||
>
|
>
|
||||||
@@ -585,6 +587,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="tcmDiagnosisH5">
|
<script setup lang="ts" name="tcmDiagnosisH5">
|
||||||
|
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||||
import {
|
import {
|
||||||
tcmDiagnosisLists,
|
tcmDiagnosisLists,
|
||||||
tcmDiagnosisDelete,
|
tcmDiagnosisDelete,
|
||||||
@@ -924,7 +927,8 @@ const appointmentRows = (row: any) => {
|
|||||||
status: row.appointment_status,
|
status: row.appointment_status,
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: row.appointment_doctor_id,
|
||||||
doctor_name: row.appointment_doctor_name,
|
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) =>
|
const activeAppointment = (row: any, videoOnly = false) =>
|
||||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
appointmentRows(row).find((apt: any) =>
|
||||||
|
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
|
||||||
|
) ?? null
|
||||||
|
|
||||||
const activeAppointmentRow = (row: any) => {
|
const activeAppointmentRow = (row: any, videoOnly = false) => {
|
||||||
const apt = activeAppointment(row)
|
const apt = activeAppointment(row, videoOnly)
|
||||||
if (!apt) return null
|
if (!apt) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -963,11 +969,13 @@ const activeAppointmentRow = (row: any) => {
|
|||||||
appointment_status: apt.status,
|
appointment_status: apt.status,
|
||||||
appointment_doctor_id: apt.doctor_id,
|
appointment_doctor_id: apt.doctor_id,
|
||||||
appointment_doctor_name: apt.doctor_name,
|
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 canCancelAppointmentRow = (row: any) => {
|
||||||
const s = Number(row.appointment_status)
|
const s = Number(row.appointment_status)
|
||||||
@@ -982,8 +990,9 @@ const isAssignedAssistant = (row: any) => {
|
|||||||
return Number(aid) === Number(userStore.userInfo?.id)
|
return Number(aid) === Number(userStore.userInfo?.id)
|
||||||
}
|
}
|
||||||
const canWatchCallEntry = (row: any) =>
|
const canWatchCallEntry = (row: any) =>
|
||||||
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
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 watchCallShowEnterButton = (row: any) => {
|
const watchCallShowEnterButton = (row: any) => {
|
||||||
const st = watchCallState(row)
|
const st = watchCallState(row)
|
||||||
return st === 'live' || st === 'pending_room'
|
return st === 'live' || st === 'pending_room'
|
||||||
@@ -1016,6 +1025,10 @@ const watchCallVisibleForRow = (row: any) => {
|
|||||||
const watchCallDialogVisible = ref(false)
|
const watchCallDialogVisible = ref(false)
|
||||||
const watchCallDiagnosisId = ref(0)
|
const watchCallDiagnosisId = ref(0)
|
||||||
const onWatchCallEntryClick = (row: any) => {
|
const onWatchCallEntryClick = (row: any) => {
|
||||||
|
if (!canWatchCallEntry(row)) {
|
||||||
|
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (watchCallState(row) !== 'live') {
|
if (watchCallState(row) !== 'live') {
|
||||||
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
|
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
|
||||||
return
|
return
|
||||||
@@ -1087,7 +1100,7 @@ const runAction = (cmd: string) => {
|
|||||||
case 'assign': handleSingleAssign(row); break
|
case 'assign': handleSingleAssign(row); break
|
||||||
case 'videoQr':
|
case 'videoQr':
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
if (!isAppointmentActiveForVideo(row)) {
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleVideoQRCode(row); break
|
handleVideoQRCode(row); break
|
||||||
@@ -1262,9 +1275,9 @@ const qrcodeAppointmentTimeText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleVideoQRCode = async (row: any) => {
|
const handleVideoQRCode = async (row: any) => {
|
||||||
const qrcodeRow = activeAppointmentRow(row)
|
const qrcodeRow = activeAppointmentRow(row, true)
|
||||||
if (!qrcodeRow) {
|
if (!qrcodeRow) {
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码'); return
|
||||||
}
|
}
|
||||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||||
lastQRCodeType.value = 'video'
|
lastQRCodeType.value = 'video'
|
||||||
@@ -1807,6 +1820,7 @@ $dh5-card-bg: #ffffff;
|
|||||||
|
|
||||||
.dh5-apt-item {
|
.dh5-apt-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -1836,6 +1850,12 @@ $dh5-card-bg: #ffffff;
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dh5-apt-type {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: $dh5-text-mute;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.dh5-apt-none {
|
.dh5-apt-none {
|
||||||
color: $dh5-warn;
|
color: $dh5-warn;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { renderToString } = require('@vue/server-renderer')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const src = path.join(__dirname, '../src')
|
||||||
|
function moduleFrom(source, mockRequire = require, browserWindow = {}) {
|
||||||
|
const code = ts.transpileModule(source.replaceAll('import.meta.env.DEV', 'false'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', 'window', code)(mockRequire, module, module.exports, browserWindow)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
const read = (file) => fs.readFileSync(path.join(src, file), 'utf8')
|
||||||
|
const types = moduleFrom(read('utils/appointment-type.ts'))
|
||||||
|
const { descriptor } = parse(read('components/chat-dialog/index.vue'))
|
||||||
|
const script = compileScript(descriptor, { id: 'appointment-call-mode' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename: 'chat-dialog.vue', id: 'appointment-call-mode', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const render = moduleFrom(template.code).render
|
||||||
|
const passthrough = { setup: (_, { slots }) => () => vue.h('div', [slots.default?.(), slots.headerToolbar?.()]) }
|
||||||
|
const picker = text => ({ setup: () => () => vue.h('button', text) })
|
||||||
|
|
||||||
|
function chat(policy = {}) {
|
||||||
|
const requests = [], warnings = [], sdkCalls = []
|
||||||
|
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
|
||||||
|
const server = Object.fromEntries(['calls', 'call', 'groupCall'].map(name => [name, async params => { sdkCalls.push({ name, params }) }]))
|
||||||
|
const api = { getCallSignature: async params => { requests.push(params); return policy } }
|
||||||
|
const result = moduleFrom(script.content, name => {
|
||||||
|
if (name === 'vue') return { ...vue, onMounted() {}, onUnmounted() {} }
|
||||||
|
if (name === '@/utils/appointment-type') return types
|
||||||
|
if (name === '@/utils/appointment-call-guard') return guard
|
||||||
|
if (name === '@/utils/im-chat-archive-trigger') return moduleFrom(read('utils/im-chat-archive-trigger.ts'))
|
||||||
|
if (name === '@/api/tcm') return api
|
||||||
|
if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } }
|
||||||
|
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: {} }) }
|
||||||
|
if (name === '@/utils/call-local-recorder') return { CallLocalRecorder: class {} }
|
||||||
|
if (name === '@tencentcloud/chat-uikit-vue3') return {
|
||||||
|
useLoginState: () => ({}), useConversationListState: () => ({ activeConversation: vue.ref(null) }),
|
||||||
|
Chat: passthrough, UIKitProvider: passthrough, MessageInput: passthrough, MessageList: passthrough,
|
||||||
|
EmojiPicker: picker('表情'), ImagePicker: picker('图片'), FilePicker: picker('文件'),
|
||||||
|
AudioCallPicker: picker('语音拨打'), VideoCallPicker: picker('视频拨打')
|
||||||
|
}
|
||||||
|
if (name === '@tencentcloud/call-uikit-vue') return { TUICallKitServer: server, TUICallType: { VIDEO_CALL: 2 }, TUIStore: {}, StoreName: {}, NAME: {} }
|
||||||
|
return {}
|
||||||
|
}).default.setup({}, { expose() {} })
|
||||||
|
result.visible.value = true
|
||||||
|
result.isReady.value = true
|
||||||
|
result.isCallReady.value = true
|
||||||
|
result.patientId.value = 50
|
||||||
|
result.diagnosisId.value = 70
|
||||||
|
result.appointmentId.value = 90
|
||||||
|
result.syncAppointmentCallPolicy(policy)
|
||||||
|
guard.registerAppointmentCallGuard(result.prepareAppointmentCall)
|
||||||
|
return { state: result, guard, server, requests, warnings, sdkCalls, api }
|
||||||
|
}
|
||||||
|
const policy = type => ({ appointment_id: 90, appointment_type: type, appointment_type_desc: types.appointmentTypeDescription(type), can_video_call: type === 'video', can_audio_call: type === 'video' || type === 'phone', call_disabled_reason: type === 'text' ? '图文问诊不支持音视频通话' : '当前挂号不支持该通话方式' })
|
||||||
|
|
||||||
|
test('type labels and video capability preserve legacy video while rejecting text and unknown', () => {
|
||||||
|
for (const value of ['text', 'phone', 'unknown']) assert.equal(types.canAppointmentVideoCall(value), false)
|
||||||
|
for (const value of ['video', '', ' ', null, undefined]) assert.equal(types.canAppointmentVideoCall(value), true)
|
||||||
|
assert.equal(types.appointmentTypeDescription('text'), '图文问诊')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rendered real chat template keeps text tools while excluding call controls in text mode', async () => {
|
||||||
|
async function markup(type) {
|
||||||
|
const { state } = chat(policy(type))
|
||||||
|
const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) })
|
||||||
|
app.config.warnHandler = () => {}
|
||||||
|
for (const name of ['el-tag', 'el-button', 'el-tooltip', 'el-icon', 'el-alert']) app.component(name, passthrough)
|
||||||
|
const context = {}
|
||||||
|
await renderToString(app, context)
|
||||||
|
return context.teleports.body
|
||||||
|
}
|
||||||
|
const text = await markup('text')
|
||||||
|
assert.match(text, /图文问诊/)
|
||||||
|
for (const tool of ['表情', '图片', '文件']) assert.match(text, new RegExp(tool))
|
||||||
|
assert.doesNotMatch(text, /视频拨打|语音拨打|群视频/)
|
||||||
|
const video = await markup('video')
|
||||||
|
assert.match(video, /视频问诊/)
|
||||||
|
assert.match(video, /视频拨打/)
|
||||||
|
assert.match(video, /群视频/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('text mode hides all live call entries and refuses direct group invocation', async () => {
|
||||||
|
const { state, requests, sdkCalls } = chat(policy('text'))
|
||||||
|
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
|
||||||
|
assert.equal(state.canVideoCall.value, false)
|
||||||
|
assert.equal(state.canAudioCall.value, false)
|
||||||
|
await state.startGroupVideoCall()
|
||||||
|
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
|
||||||
|
assert.deepEqual(requests, [])
|
||||||
|
assert.deepEqual(sdkCalls, [])
|
||||||
|
assert.match(descriptor.template.content, /<VideoCallPicker v-if="isCallReady && canVideoCall"/)
|
||||||
|
assert.match(descriptor.template.content, /<AudioCallPicker v-if="isCallReady && canAudioCall"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all SDK outgoing paths stop before touching SDK when current appointment is text', async () => {
|
||||||
|
const { state, server, sdkCalls } = chat(policy('text'))
|
||||||
|
state.installTUICallKitRoomHooks()
|
||||||
|
for (const method of ['call', 'calls', 'groupCall']) await assert.rejects(server[method]({ type: 2 }), /图文问诊/)
|
||||||
|
assert.deepEqual(sdkCalls, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('video preflight uses actual appointment and prevents a server-side change to text', async () => {
|
||||||
|
const { state, requests, api } = chat(policy('video'))
|
||||||
|
assert.equal(state.canVideoCall.value, true)
|
||||||
|
await state.prepareAppointmentCall(2)
|
||||||
|
assert.deepEqual(requests, [{ patient_id: 50, diagnosis_id: 70, appointment_id: 90 }])
|
||||||
|
api.getCallSignature = async () => policy('text')
|
||||||
|
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
|
||||||
|
assert.equal(state.canVideoCall.value, false)
|
||||||
|
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('historical phone is audio-only; missing server confirmation never enables video', async () => {
|
||||||
|
const { state } = chat(policy('phone'))
|
||||||
|
assert.equal(state.canAudioCall.value, true)
|
||||||
|
assert.equal(state.canVideoCall.value, false)
|
||||||
|
await state.prepareAppointmentCall(1)
|
||||||
|
await assert.rejects(state.prepareAppointmentCall(2))
|
||||||
|
state.syncAppointmentCallPolicy({ appointment_type: 'video' })
|
||||||
|
assert.equal(state.canVideoCall.value, false)
|
||||||
|
state.syncAppointmentCallPolicy({ appointment_type: null, appointment_type_desc: '未挂号', can_video_call: false, can_audio_call: false })
|
||||||
|
assert.equal(state.appointmentTypeLabel.value, '未挂号')
|
||||||
|
assert.equal(state.canVideoCall.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('singleton call guard switches to current chat and cancels a pending old context', async () => {
|
||||||
|
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
|
||||||
|
let resolveOld
|
||||||
|
const releaseOld = guard.registerAppointmentCallGuard(() => new Promise(resolve => { resolveOld = resolve }))
|
||||||
|
const pending = guard.checkAppointmentOutgoingCall({ type: 2 })
|
||||||
|
const releaseNew = guard.registerAppointmentCallGuard(async () => { throw new Error('图文问诊') })
|
||||||
|
releaseOld()
|
||||||
|
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /图文问诊/)
|
||||||
|
resolveOld()
|
||||||
|
await assert.rejects(pending, /问诊已切换/)
|
||||||
|
releaseNew()
|
||||||
|
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { DatabaseSync } = require('node:sqlite')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '../..')
|
||||||
|
const sql = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [path.join(root, 'server/tests/AppointmentTypeFilterSqlFixture.php')], { encoding: 'utf8' }))
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE doctor_appointment (id INTEGER, patient_id INTEGER, doctor_id INTEGER, assistant_id INTEGER, appointment_type TEXT, status INTEGER, appointment_date TEXT, appointment_time TEXT);
|
||||||
|
CREATE TABLE tcm_diagnosis (id INTEGER, patient_id INTEGER, patient_name TEXT, phone TEXT, gender INTEGER, age INTEGER, weight INTEGER, height INTEGER, assistant_id INTEGER, delete_time INTEGER);
|
||||||
|
CREATE TABLE admin (id INTEGER, name TEXT);
|
||||||
|
INSERT INTO tcm_diagnosis VALUES (100, 1000, '患者甲', '', 1, 30, 60, 170, 301, NULL), (101, 1001, '患者乙', '', 1, 31, 61, 171, 302, NULL), (102, 1002, '已删除', '', 1, 30, 60, 170, 301, 1);
|
||||||
|
INSERT INTO admin VALUES (201, '医生甲'), (202, '医生乙'), (301, '医助甲'), (302, '医助乙');
|
||||||
|
`)
|
||||||
|
const add = db.prepare('INSERT INTO doctor_appointment VALUES (?, ?, ?, 301, ?, ?, ?, ?)')
|
||||||
|
const fixtures = [
|
||||||
|
[1, 100, 201, 'video', 1],
|
||||||
|
[2, 100, 201, null, 1],
|
||||||
|
[3, 100, 201, '', 2],
|
||||||
|
[4, 101, 202, ' ', 3],
|
||||||
|
[5, 101, 202, '\t\r\n\v ', 4],
|
||||||
|
[6, 100, 201, 'text', 1],
|
||||||
|
[7, 101, 202, 'text', 3],
|
||||||
|
[8, 100, 201, 'phone', 1],
|
||||||
|
[9, 100, 201, 'unknown', 1],
|
||||||
|
[10, 100, 201, 'Video', 1],
|
||||||
|
[11, 100, 201, 'video ', 1],
|
||||||
|
[12, 102, 201, null, 1],
|
||||||
|
[13, 102, 201, 'text', 1]
|
||||||
|
]
|
||||||
|
for (const [id, patient, doctor, type, status] of fixtures) add.run(id, patient, doctor, type, status, '2026-09-09', `${String(id).padStart(2, '0')}:00:00`)
|
||||||
|
|
||||||
|
// SQLite's explicit BINARY collation has the same exact text comparison semantics used here.
|
||||||
|
// The remaining statements are the SQL emitted by the real ThinkPHP list/count/tab paths.
|
||||||
|
const execute = statement => db.prepare(statement.replace(/BINARY a\.appointment_type/g, 'a.appointment_type COLLATE BINARY')).all()
|
||||||
|
const result = name => ({
|
||||||
|
ids: execute(sql[name].lists).map(row => row.id),
|
||||||
|
count: execute(sql[name].count)[0].think_count,
|
||||||
|
tabs: Object.fromEntries(execute(sql[name].tabs).map(row => [row.status, row.cnt]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('video query includes legacy blanks and keeps list, total and status counts aligned', () => {
|
||||||
|
assert.deepEqual(result('video'), { ids: [1, 2, 3, 4, 5], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('text query excludes video, unknown and historical phone records', () => {
|
||||||
|
assert.deepEqual(result('text'), { ids: [6, 7], count: 2, tabs: { 1: 1, 3: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all and omitted filters retain historical types without admitting deleted diagnoses', () => {
|
||||||
|
for (const name of ['omitted', 'all', 'null']) {
|
||||||
|
const actual = result(name)
|
||||||
|
assert.equal(actual.count, 11)
|
||||||
|
assert.deepEqual(actual.ids.toSorted((a, b) => a - b), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||||
|
assert.equal(Object.values(actual.tabs).reduce((sum, count) => sum + count, 0), 11)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('type filter composes with status, doctor and patient filters without OR leakage', () => {
|
||||||
|
assert.deepEqual(result('video_status_1'), { ids: [1, 2], count: 2, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||||
|
assert.deepEqual(result('text_doctor'), { ids: [6], count: 1, tabs: { 1: 1 } })
|
||||||
|
assert.deepEqual(result('video_patient'), { ids: [4, 5], count: 2, tabs: { 3: 1, 4: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pagination limits only visible rows while count and tabs retain the full filtered set', () => {
|
||||||
|
assert.deepEqual(result('video_page'), { ids: [2, 3], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsupported filter values return no rows or counts instead of being converted to video', () => {
|
||||||
|
for (const name of Object.keys(sql).filter(name => name.startsWith('invalid_'))) {
|
||||||
|
assert.deepEqual(result(name), { ids: [], count: 0, tabs: {} }, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function moduleFrom(source, dependencies = require, globals = {}) {
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(dependencies, module, module.exports, ...Object.values(globals))
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageFile = path.join(root, 'admin/src/views/consumer/prescription/guahao.vue')
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(pageFile, 'utf8'), { filename: pageFile })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'appointment-type-filter' })
|
||||||
|
|
||||||
|
test('actual page and paging hook send type on query and clear it together with all fields on reset', async () => {
|
||||||
|
const calls = []
|
||||||
|
const paging = moduleFrom(fs.readFileSync(path.join(root, 'admin/src/hooks/usePaging.ts'), 'utf8'))
|
||||||
|
const component = moduleFrom(script.content, name => {
|
||||||
|
if (name === 'vue') return vue
|
||||||
|
if (name === '@/hooks/usePaging') return paging
|
||||||
|
if (name === '@/api/doctor') return { appointmentLists: async params => { calls.push({ ...params }); return { lists: [], count: 0 } } }
|
||||||
|
if (name === '@/utils/appointment-type') return { appointmentTypeDescription: value => value }
|
||||||
|
return {}
|
||||||
|
}, { reactive: vue.reactive, ref: vue.ref, computed: vue.computed, onMounted() {}, onActivated() {} })
|
||||||
|
const page = component.default.setup({}, { expose() {} })
|
||||||
|
assert.equal(calls[0].appointment_type, '')
|
||||||
|
page.pager.page = 4
|
||||||
|
page.queryParams.appointment_type = 'text'
|
||||||
|
page.queryParams.patient_name = '张'
|
||||||
|
page.resetPage()
|
||||||
|
assert.equal(calls.at(-1).page_no, 1)
|
||||||
|
assert.equal(calls.at(-1).appointment_type, 'text')
|
||||||
|
assert.equal(calls.at(-1).patient_name, '张')
|
||||||
|
page.pager.page = 3
|
||||||
|
page.queryParams.appointment_type = 'video'
|
||||||
|
page.resetPage()
|
||||||
|
assert.equal(calls.at(-1).appointment_type, 'video')
|
||||||
|
page.resetFilter()
|
||||||
|
assert.equal(calls.at(-1).appointment_type, '')
|
||||||
|
assert.equal(calls.at(-1).patient_name, '')
|
||||||
|
assert.equal(calls.at(-1).page_no, 1)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('updated filter template compiles with existing page bindings', () => {
|
||||||
|
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: pageFile, id: 'appointment-type-filter', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../src/utils/im-chat-archive-trigger.ts'), 'utf8')
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
const mod = { exports: {} }
|
||||||
|
new Function('module', 'exports', compiled)(mod, mod.exports)
|
||||||
|
const { createImChatArchiveTrigger } = mod.exports
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve, reject
|
||||||
|
const promise = new Promise((a, b) => { resolve = a; reject = b })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('all pages use server-owned current-doctor scope and carry the continuation token', async () => {
|
||||||
|
const calls = []
|
||||||
|
const sync = createImChatArchiveTrigger(async params => {
|
||||||
|
calls.push(params)
|
||||||
|
return { completed: calls.length === 3, sync_token: 'next-page' }
|
||||||
|
})
|
||||||
|
await sync(12116)
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ diagnosis_id: 12116, scope: 'current', sync_token: undefined },
|
||||||
|
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' },
|
||||||
|
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('many events while syncing coalesce, then perform one catch-up scan for new messages', async () => {
|
||||||
|
const page = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const sync = createImChatArchiveTrigger(() => {
|
||||||
|
calls++
|
||||||
|
return calls === 1 ? page.promise : Promise.resolve({ completed: true })
|
||||||
|
})
|
||||||
|
const first = sync(10)
|
||||||
|
assert.equal(sync(10), first)
|
||||||
|
assert.equal(sync(10), first)
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
page.resolve({ completed: true })
|
||||||
|
await first
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
await sync(10)
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching patients keeps in-flight requests bound to their original diagnosis', async () => {
|
||||||
|
const old = deferred(), calls = []
|
||||||
|
const sync = createImChatArchiveTrigger(params => {
|
||||||
|
calls.push(params)
|
||||||
|
if (params.diagnosis_id === 10 && !params.sync_token) return old.promise
|
||||||
|
return Promise.resolve({ completed: true })
|
||||||
|
})
|
||||||
|
const first = sync(10)
|
||||||
|
await sync(20)
|
||||||
|
old.resolve({ completed: false, sync_token: 'old-patient' })
|
||||||
|
await first
|
||||||
|
assert.deepEqual(calls.map(p => [p.diagnosis_id, p.sync_token]), [[10, undefined], [20, undefined], [10, 'old-patient']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('partial failure and network failure are reported and do not poison later retries', async () => {
|
||||||
|
const warnings = []
|
||||||
|
let count = 0
|
||||||
|
const sync = createImChatArchiveTrigger(async () => {
|
||||||
|
count++
|
||||||
|
if (count === 1) return { completed: true, errors: ['cloud unavailable'] }
|
||||||
|
if (count === 2) throw new Error('request timeout')
|
||||||
|
return { completed: true }
|
||||||
|
}, error => warnings.push(error.message))
|
||||||
|
await sync(10)
|
||||||
|
await sync(10)
|
||||||
|
await sync(10)
|
||||||
|
assert.deepEqual(warnings, ['cloud unavailable', 'request timeout'])
|
||||||
|
assert.equal(count, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid IDs never request and missing progress stops a malformed response loop', async () => {
|
||||||
|
const warnings = []
|
||||||
|
let count = 0
|
||||||
|
const sync = createImChatArchiveTrigger(async () => { count++; return { completed: false } }, error => warnings.push(error.message))
|
||||||
|
for (const id of [0, -1, NaN, 1.5]) await sync(id)
|
||||||
|
assert.equal(count, 0)
|
||||||
|
await sync(10)
|
||||||
|
assert.equal(count, 1)
|
||||||
|
assert.match(warnings[0], /未返回进度/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
function loadTs(filename, dependencies = require) {
|
||||||
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createImChatHistory } = loadTs(path.join(__dirname, '../src/utils/im-chat-history.ts'))
|
||||||
|
const archive = (id, text = '已归档消息') => ({ lists: [{ msg_id: id, text }], patient_im_id: `patient_${id}`, patient_name: `患者${id}` })
|
||||||
|
const progress = (completed, extra = {}) => ({ completed, sync_token: 'session-token', inserted: 2, processed_peers: 1, total_peers: 2, ...extra })
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve, reject
|
||||||
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
const settle = async () => { for (let i = 0; i < 50; i++) await Promise.resolve() }
|
||||||
|
|
||||||
|
function fixture(overrides = {}) {
|
||||||
|
let time = 100000, nextTimer = 0
|
||||||
|
const timers = new Map(), calls = [], notices = []
|
||||||
|
const controller = createImChatHistory({
|
||||||
|
load: async (id) => { calls.push(['load', id]); return overrides.load ? overrides.load(id) : archive(id) },
|
||||||
|
sync: async (id, token) => { calls.push(['sync', id, token]); return overrides.sync ? overrides.sync(id, token) : progress(true) },
|
||||||
|
notify: (kind, message) => notices.push({ kind, message }),
|
||||||
|
now: () => time,
|
||||||
|
setTimer: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id },
|
||||||
|
clearTimer: (id) => timers.delete(id),
|
||||||
|
visible: overrides.visible
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
controller, state: controller.state, calls, notices, timers,
|
||||||
|
setTime(value) { time = value },
|
||||||
|
advance(ms) {
|
||||||
|
time += ms
|
||||||
|
for (const [id, timer] of [...timers]) {
|
||||||
|
if (timer.at <= time) { timers.delete(id); timer.callback() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('archive appears immediately while automatic cloud sync is pending, then refreshes on completion', async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
let reads = 0
|
||||||
|
const f = fixture({ load: async () => archive(++reads), sync: () => pending.promise })
|
||||||
|
f.controller.setDiagnosis(10)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
assert.equal(f.state.syncing, true)
|
||||||
|
assert.equal(f.state.loading, false)
|
||||||
|
assert.equal(f.timers.size, 0)
|
||||||
|
pending.resolve(progress(true))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 2)
|
||||||
|
assert.equal(f.state.syncing, false)
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
assert.deepEqual(f.notices, [])
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('account verification progress and skipped staff are displayed separately from sync failures', async () => {
|
||||||
|
const checked = deferred(), finished = deferred()
|
||||||
|
let step = 0
|
||||||
|
const f = fixture({ sync: async () => {
|
||||||
|
step++
|
||||||
|
if (step === 1) return progress(false, { phase: 'checking_accounts', checked_accounts: 100, candidate_accounts: 185, skipped_accounts: 98 })
|
||||||
|
if (step === 2) return checked.promise
|
||||||
|
return finished.promise
|
||||||
|
} })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.phase, 'checking_accounts')
|
||||||
|
assert.equal(f.state.checkedAccounts, 100)
|
||||||
|
assert.equal(f.state.candidateAccounts, 185)
|
||||||
|
assert.deepEqual(f.state.partialErrors, [])
|
||||||
|
checked.resolve(progress(false, { phase: 'syncing', checked_accounts: 185, candidate_accounts: 185, skipped_accounts: 183, total_peers: 1 }))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.totalPeers, 1)
|
||||||
|
assert.equal(f.state.skippedAccounts, 183)
|
||||||
|
finished.resolve(progress(true, { phase: 'completed', skipped_accounts: 183, total_peers: 1 }))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.syncError, '')
|
||||||
|
assert.deepEqual(f.state.partialErrors, [])
|
||||||
|
f.controller.setDiagnosis(2)
|
||||||
|
assert.equal(f.state.skippedAccounts, 0)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completion waits for initial archive read and starts a fresh read after it', async () => {
|
||||||
|
const initial = deferred()
|
||||||
|
let reads = 0
|
||||||
|
const f = fixture({ load: () => ++reads === 1 ? initial.promise : Promise.resolve(archive(2)) })
|
||||||
|
f.controller.setDiagnosis(10)
|
||||||
|
await settle()
|
||||||
|
assert.equal(reads, 1)
|
||||||
|
initial.resolve(archive(1))
|
||||||
|
await settle()
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 2)
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sync tokens continue across pages and archive refreshes every three pages and on completion', async () => {
|
||||||
|
const lastPage = deferred()
|
||||||
|
let pages = 0, reads = 0
|
||||||
|
const f = fixture({
|
||||||
|
load: async () => archive(++reads),
|
||||||
|
sync: async () => ++pages < 4 ? progress(false, { sync_token: `token-${pages}`, inserted: pages }) : lastPage.promise
|
||||||
|
})
|
||||||
|
f.controller.setDiagnosis(10)
|
||||||
|
await settle()
|
||||||
|
assert.equal(pages, 4)
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
assert.deepEqual(f.calls.filter(call => call[0] === 'sync').map(call => call[2]), [undefined, 'token-1', 'token-2', 'token-3'])
|
||||||
|
assert.equal(f.state.inserted, 3)
|
||||||
|
lastPage.resolve(progress(true, { inserted: 4, processed_peers: 2 }))
|
||||||
|
await settle()
|
||||||
|
assert.equal(reads, 3)
|
||||||
|
assert.equal(f.state.inserted, 4)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a slow page refreshes archive after two seconds even before three pages', async () => {
|
||||||
|
const lastPage = deferred()
|
||||||
|
let pages = 0, reads = 0
|
||||||
|
const f = fixture({
|
||||||
|
load: async () => archive(++reads),
|
||||||
|
sync: async () => {
|
||||||
|
if (++pages === 1) { f.setTime(102100); return progress(false) }
|
||||||
|
return lastPage.promise
|
||||||
|
}
|
||||||
|
})
|
||||||
|
f.controller.setDiagnosis(10)
|
||||||
|
await settle()
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
assert.equal(f.state.syncing, true)
|
||||||
|
f.controller.dispose()
|
||||||
|
lastPage.resolve(progress(true))
|
||||||
|
await settle()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching diagnosis ignores old archive, old sync errors, and old completion timers', async () => {
|
||||||
|
const oldRead = deferred(), oldSync = deferred()
|
||||||
|
const f = fixture({ load: (id) => id === 1 ? oldRead.promise : Promise.resolve(archive(id)), sync: (id) => id === 1 ? oldSync.promise : Promise.resolve(progress(true)) })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
f.controller.setDiagnosis(2)
|
||||||
|
await settle()
|
||||||
|
oldRead.resolve(archive(1))
|
||||||
|
oldSync.reject(new Error('旧患者同步失败'))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.patientName, '患者2')
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 2)
|
||||||
|
assert.equal(f.state.syncError, '')
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'load' && call[1] === 1).length, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dispose invalidates in-flight requests and never refreshes or schedules after completion', async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
const f = fixture({ sync: () => pending.promise })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
const before = f.calls.length
|
||||||
|
f.controller.dispose()
|
||||||
|
pending.resolve(progress(true))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.length, before)
|
||||||
|
assert.equal(f.timers.size, 0)
|
||||||
|
assert.equal(f.state.syncing, false)
|
||||||
|
f.controller.setVisible(true)
|
||||||
|
f.controller.setDiagnosis(2)
|
||||||
|
assert.equal(f.calls.length, before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('read and sync failures preserve existing rows and expose the original errors', async () => {
|
||||||
|
let readFails = false, syncFails = false
|
||||||
|
const f = fixture({
|
||||||
|
load: async () => { if (readFails) throw { response: { data: { msg: '归档服务读取失败' } } }; return archive(1) },
|
||||||
|
sync: async () => { if (syncFails) throw new Error('腾讯云凭证无效'); return progress(true) }
|
||||||
|
})
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
readFails = true
|
||||||
|
await f.controller.reload()
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
assert.equal(f.state.readError, '归档服务读取失败')
|
||||||
|
syncFails = true
|
||||||
|
await f.controller.sync()
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
assert.equal(f.state.syncError, '腾讯云凭证无效')
|
||||||
|
assert.deepEqual(f.notices, [{ kind: 'error', message: '腾讯云凭证无效' }])
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only a manual completed sync emits success; concurrent clicks share the in-flight run', async () => {
|
||||||
|
let pending
|
||||||
|
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
assert.deepEqual(f.notices, [])
|
||||||
|
pending = deferred()
|
||||||
|
const manual = f.controller.sync()
|
||||||
|
const duplicate = f.controller.sync()
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||||
|
assert.deepEqual(f.notices, [])
|
||||||
|
pending.resolve(progress(true, { inserted: 7 }))
|
||||||
|
await Promise.all([manual, duplicate])
|
||||||
|
assert.deepEqual(f.notices, [{ kind: 'success', message: '聊天记录已更新,本次新增 7 条' }])
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completed partial failure refreshes rows, ends the loop, and never reports full success', async () => {
|
||||||
|
let partial = false, reads = 0
|
||||||
|
const f = fixture({ load: async () => archive(++reads), sync: async () => progress(true, partial ? { error: '医生账号失败', errors: ['医生账号失败', '医助账号失败'] } : {}) })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
partial = true
|
||||||
|
await f.controller.sync()
|
||||||
|
assert.deepEqual([...f.state.partialErrors], ['医生账号失败', '医助账号失败'])
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 3)
|
||||||
|
assert.equal(f.state.syncing, false)
|
||||||
|
assert.equal(f.notices.length, 1)
|
||||||
|
assert.equal(f.notices[0].kind, 'warning')
|
||||||
|
assert.match(f.notices[0].message, /医生账号失败;医助账号失败/)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing token and missing completed state stop safely without claiming success', async () => {
|
||||||
|
for (const response of [{ completed: false }, { inserted: 0 }]) {
|
||||||
|
const f = fixture({ sync: async () => response })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
|
||||||
|
assert.match(f.state.syncError, /同步接口未返回/)
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
assert.equal(f.notices.length, 0)
|
||||||
|
f.controller.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one 30-second timer starts only after completion and never overlaps a pending run', async () => {
|
||||||
|
let pending
|
||||||
|
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
f.advance(29999)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
|
||||||
|
pending = deferred()
|
||||||
|
f.advance(1)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||||
|
assert.equal(f.timers.size, 0)
|
||||||
|
f.advance(90000)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
|
||||||
|
pending.resolve(progress(true))
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
assert.equal(f.timers.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hidden or deactivated panels stop scheduling and resume with existing rows retained', async () => {
|
||||||
|
const f = fixture({ visible: false })
|
||||||
|
f.controller.setDiagnosis(1)
|
||||||
|
await settle()
|
||||||
|
assert.deepEqual(f.calls, [])
|
||||||
|
f.controller.setVisible(true)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
f.controller.setVisible(false)
|
||||||
|
assert.equal(f.timers.size, 0)
|
||||||
|
const before = f.calls.length
|
||||||
|
f.advance(60000)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.calls.length, before)
|
||||||
|
f.controller.setVisible(true)
|
||||||
|
assert.equal(f.state.rows[0].msg_id, 1)
|
||||||
|
await settle()
|
||||||
|
assert.equal(f.timers.size, 1)
|
||||||
|
f.controller.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('IM API functions retain raw backend reasons, send progress tokens, and disable retries', async () => {
|
||||||
|
const calls = []
|
||||||
|
let reply = { code: 1, data: progress(true), msg: '不应自动显示的成功提示', show: 1 }
|
||||||
|
const request = {}
|
||||||
|
for (const method of ['get', 'post']) request[method] = async (config, options) => { calls.push({ method, config, options }); return reply }
|
||||||
|
const api = loadTs(path.join(__dirname, '../src/api/tcm.ts'), (name) => {
|
||||||
|
if (name === '@/utils/request') return { default: request }
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
})
|
||||||
|
assert.equal((await api.triggerImChatSync({ diagnosis_id: 2, sync_token: 'resume' })).completed, true)
|
||||||
|
reply = { code: 1, data: archive(2) }
|
||||||
|
assert.equal((await api.getImChatMessages({ diagnosis_id: 2, only_archived: 1 })).lists[0].msg_id, 2)
|
||||||
|
assert.deepEqual(calls[0].config.data, { diagnosis_id: 2, sync_token: 'resume' })
|
||||||
|
for (const call of calls) {
|
||||||
|
assert.equal(call.config.timeout, 30000)
|
||||||
|
assert.deepEqual(call.options, { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 })
|
||||||
|
}
|
||||||
|
reply = { code: 0, data: [], msg: '腾讯云错误:签名校验失败' }
|
||||||
|
await assert.rejects(api.triggerImChatSync({ diagnosis_id: 2 }), /腾讯云错误:签名校验失败/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chat panel script, template, and scoped styles compile', () => {
|
||||||
|
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'im-chat-history' })
|
||||||
|
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'im-chat-history', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||||
|
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'im-chat-history', scoped: true, preprocessLang: 'scss' }).errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('actual panel setup wires archived reads, continued sync, patient switches, and lifecycle cleanup', async () => {
|
||||||
|
const vue = require('vue')
|
||||||
|
const hooks = {}, listeners = new Map(), timers = new Map(), apiCalls = []
|
||||||
|
const pending = deferred()
|
||||||
|
let timerId = 0
|
||||||
|
const originalDocument = global.document
|
||||||
|
global.document = {
|
||||||
|
visibilityState: 'visible',
|
||||||
|
addEventListener: (name, callback) => listeners.set(name, callback),
|
||||||
|
removeEventListener: (name) => listeners.delete(name)
|
||||||
|
}
|
||||||
|
const scope = vue.effectScope()
|
||||||
|
try {
|
||||||
|
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
|
||||||
|
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
const script = compileScript(descriptor, { id: 'im-chat-panel-setup' })
|
||||||
|
const compiled = ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', compiled)((name) => {
|
||||||
|
if (name === 'vue') return { ...vue, ...Object.fromEntries(['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map(hook => [hook, callback => { hooks[hook] = callback }])) }
|
||||||
|
if (name === 'dayjs') return { default: require('dayjs') }
|
||||||
|
if (name === '@element-plus/icons-vue') return {}
|
||||||
|
if (name === 'element-plus') return { ElMessage: { success() {}, warning() {}, error() {} } }
|
||||||
|
if (name === '@/utils/im-business-message-parse') return { parseImBusinessPayload: () => null }
|
||||||
|
if (name === '@/utils/im-chat-history') return { createImChatHistory: (deps) => createImChatHistory({ ...deps, setTimer: callback => { const id = ++timerId; timers.set(id, callback); return id }, clearTimer: id => timers.delete(id) }) }
|
||||||
|
if (name === '@/api/tcm') return {
|
||||||
|
getImChatMessages: async params => { apiCalls.push(['read', params]); return archive(params.diagnosis_id) },
|
||||||
|
triggerImChatSync: async params => { apiCalls.push(['sync', params]); return params.diagnosis_id === 1 ? pending.promise : progress(true) }
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected panel import: ${name}`)
|
||||||
|
}, module, module.exports)
|
||||||
|
const props = vue.reactive({ diagnosisId: 1 })
|
||||||
|
const panel = scope.run(() => module.exports.default.setup(props, { expose() {} }))
|
||||||
|
hooks.onMounted()
|
||||||
|
await settle()
|
||||||
|
assert.equal(panel.rows.value[0].raw.msg_id, 1)
|
||||||
|
assert.deepEqual(apiCalls[0], ['read', { diagnosis_id: 1, only_archived: 1 }])
|
||||||
|
assert.deepEqual(apiCalls[1], ['sync', { diagnosis_id: 1 }])
|
||||||
|
props.diagnosisId = 2
|
||||||
|
await settle()
|
||||||
|
assert.equal(panel.rows.value[0].raw.msg_id, 2)
|
||||||
|
pending.resolve(progress(true))
|
||||||
|
await settle()
|
||||||
|
assert.equal(panel.rows.value[0].raw.msg_id, 2)
|
||||||
|
assert.equal(timers.size, 1)
|
||||||
|
panel.history.rows = [{
|
||||||
|
msg_id: 'multi-part', msg_type: 'composite', from_account: 'doctor_20', to_account: 'patient_2', time: 1700000000,
|
||||||
|
parts: [{ msg_type: 'text', text: '第一段' }, { msg_type: 'image', image_url: 'https://example.invalid/chat.png' }, { msg_type: 'text', text: '第三段' }]
|
||||||
|
}]
|
||||||
|
assert.deepEqual(panel.rows.value.map(item => [item.raw.msg_id, item.raw.msg_type]), [
|
||||||
|
['multi-part:0', 'text'], ['multi-part:1', 'image'], ['multi-part:2', 'text']
|
||||||
|
])
|
||||||
|
assert.equal(panel.rows.value[2].raw.text, '第三段')
|
||||||
|
assert.ok(panel.rows.value.every(item => item.raw.to_account === 'patient_2'))
|
||||||
|
global.document.visibilityState = 'hidden'
|
||||||
|
listeners.get('visibilitychange')()
|
||||||
|
assert.equal(timers.size, 0)
|
||||||
|
hooks.onBeforeUnmount()
|
||||||
|
assert.equal(listeners.size, 0)
|
||||||
|
} finally {
|
||||||
|
scope.stop()
|
||||||
|
global.document = originalDocument
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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 { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const handlers = [
|
||||||
|
'isDualAuditPassed', 'canQuickTrackRow', 'canShipRow', 'openQuickTrack',
|
||||||
|
'submitQuickTrack', 'openShip', 'openEdit', 'submitEdit', 'resetEditOrderDialog'
|
||||||
|
]
|
||||||
|
const stateNames = [
|
||||||
|
'quickTrackVisible', 'quickTrackSaving', 'quickTrackRowId', 'quickTrackForm',
|
||||||
|
'shipVisible', 'shipRowId', 'shipForm', 'editVisible', 'editDialogLoading',
|
||||||
|
'editSaving', 'editOrderStep', 'editOrderPrescription',
|
||||||
|
'editFormRef', 'editForm', 'editDepositMin'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const page of ['order_list.vue', 'order_list_h5.vue']) {
|
||||||
|
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: page })
|
||||||
|
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
|
||||||
|
// Execute the actual order handlers with only network and unrelated UI dependencies stubbed.
|
||||||
|
const declarations = new Map()
|
||||||
|
for (const node of ast.statements) {
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
|
||||||
|
if (ts.isVariableStatement(node)) {
|
||||||
|
for (const declaration of node.declarationList.declarations) {
|
||||||
|
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = [...handlers, ...stateNames]
|
||||||
|
for (const name of selected) assert.ok(declarations.has(name), `${page}: missing ${name}`)
|
||||||
|
const compiled = ts.transpileModule(selected.map(name => declarations.get(name)).join('\n'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function instance(detail = {}, saveTracking = async () => {}) {
|
||||||
|
const warnings = []
|
||||||
|
const errors = []
|
||||||
|
const edits = []
|
||||||
|
const tracks = []
|
||||||
|
const globals = {
|
||||||
|
ref: vue.ref, reactive: vue.reactive, nextTick: vue.nextTick,
|
||||||
|
feedback: { msgWarning: message => warnings.push(message), msgError: message => errors.push(message), msgSuccess() {} },
|
||||||
|
prescriptionOrderDetail: async () => ({ data: detail }),
|
||||||
|
prescriptionOrderEdit: async payload => edits.push(payload),
|
||||||
|
prescriptionOrderDdcode: async payload => { tracks.push(payload); await saveTracking(payload) },
|
||||||
|
canEditRow: () => true,
|
||||||
|
parseServicePackageValues: () => [],
|
||||||
|
loadEditPaidOrders: async () => {},
|
||||||
|
resolveShipModeForRow: row => row.ship_mode || 'gancao',
|
||||||
|
editGancaoLogisticsOnlyMode: vue.ref(false),
|
||||||
|
editGancaoDisplayNo: vue.ref(''),
|
||||||
|
detailDrawerRef: vue.ref(null),
|
||||||
|
detailVisible: vue.ref(false),
|
||||||
|
getLists() {}
|
||||||
|
}
|
||||||
|
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
|
||||||
|
state.editFormRef.value = { validate: async () => {}, clearValidate() {} }
|
||||||
|
return { state, warnings, errors, edits, tracks }
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`${page}: only two approved audits unlock tracking, including legacy string statuses`, () => {
|
||||||
|
const { state } = instance()
|
||||||
|
for (const rx of [undefined, null, 0, 1, 2, '0', '1', '2']) {
|
||||||
|
for (const pay of [undefined, null, 0, 1, 2, '0', '1', '2']) {
|
||||||
|
const row = { prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 }
|
||||||
|
const allowed = [1, '1'].includes(rx) && [1, '1'].includes(pay)
|
||||||
|
assert.equal(state.canQuickTrackRow(row), allowed, `tracking: ${rx}/${pay}`)
|
||||||
|
assert.equal(state.canShipRow(row), allowed, `shipping: ${rx}/${pay}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const status of [1, 3, 4, 5, 6, 9]) {
|
||||||
|
assert.equal(state.canShipRow({ prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: status }), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: direct handler calls cannot open tracking or shipping before both approvals`, () => {
|
||||||
|
for (const [rx, pay] of [[0, 0], [1, 0], [0, 1], [2, 1], [1, 2]]) {
|
||||||
|
const { state, warnings } = instance()
|
||||||
|
const row = { id: 42, prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 }
|
||||||
|
state.openQuickTrack(row)
|
||||||
|
state.openShip(row)
|
||||||
|
assert.equal(state.quickTrackVisible.value, false)
|
||||||
|
assert.equal(state.shipVisible.value, false)
|
||||||
|
assert.equal(state.quickTrackRowId.value, 0)
|
||||||
|
assert.equal(state.shipRowId.value, 0)
|
||||||
|
assert.equal(warnings.length, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: approved orders can fill or replace the number and preserve the carrier`, async () => {
|
||||||
|
for (const oldNumber of ['', 'SF-OLD']) {
|
||||||
|
const { state, tracks } = instance()
|
||||||
|
const row = { id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: 2, express_company: 'sf', tracking_number: oldNumber }
|
||||||
|
state.openQuickTrack(row)
|
||||||
|
assert.equal(state.quickTrackVisible.value, true)
|
||||||
|
assert.equal(state.quickTrackForm.tracking_number, oldNumber)
|
||||||
|
state.quickTrackForm.tracking_number = ' SF-NEW '
|
||||||
|
await state.submitQuickTrack()
|
||||||
|
assert.deepEqual(tracks, [{ id: 42, express_company: 'sf', tracking_number: 'SF-NEW' }])
|
||||||
|
assert.equal(state.quickTrackVisible.value, false)
|
||||||
|
state.openShip(row)
|
||||||
|
assert.equal(state.shipVisible.value, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: a server rejection after audit revocation retains the dialog and entered number`, async () => {
|
||||||
|
const { state } = instance({}, async () => { throw new Error('audit revoked') })
|
||||||
|
state.openQuickTrack({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 })
|
||||||
|
state.quickTrackForm.tracking_number = 'SF-NEW'
|
||||||
|
await state.submitQuickTrack()
|
||||||
|
assert.equal(state.quickTrackVisible.value, true)
|
||||||
|
assert.equal(state.quickTrackSaving.value, false)
|
||||||
|
assert.equal(state.quickTrackForm.tracking_number, 'SF-NEW')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: editing preserves the existing number while saving other fields before approval`, async () => {
|
||||||
|
const { state, edits, errors } = instance({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 0, tracking_number: 'SF-EXISTING' })
|
||||||
|
await state.openEdit({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
assert.equal(state.editForm.tracking_number, 'SF-EXISTING')
|
||||||
|
state.editForm.tracking_number = 'FORGED'
|
||||||
|
state.editForm.recipient_name = '修改后的收货人'
|
||||||
|
state.editOrderStep.value = 2
|
||||||
|
await state.submitEdit()
|
||||||
|
assert.equal(edits.length, 1)
|
||||||
|
assert.equal(edits[0].recipient_name, '修改后的收货人')
|
||||||
|
assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: ordinary editing cannot change tracking while resetting an approved payment audit`, async () => {
|
||||||
|
const { state, edits, errors } = instance({ id: 42, prescription_audit_status: '1', payment_slip_audit_status: '1' })
|
||||||
|
await state.openEdit({ id: 42 })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
state.editForm.tracking_number = 'SF-NEW'
|
||||||
|
state.editOrderStep.value = 2
|
||||||
|
await state.submitEdit()
|
||||||
|
assert.equal(edits.length, 1)
|
||||||
|
assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: template compiles with tracking controls bound to audit restrictions`, () => {
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: page, compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const inputs = [...descriptor.template.content.matchAll(/<el-input\b[^>]*v-model="editForm\.tracking_number"[^>]*>/g)]
|
||||||
|
assert.equal(inputs.length, page === 'order_list.vue' ? 2 : 1)
|
||||||
|
for (const [input] of inputs) assert.match(input, /\sdisabled(?:\s|\/?>)/)
|
||||||
|
assert.match(descriptor.template.content, /v-if="canQuickTrackRow\(row\)"/)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 已开处方 → AI 界面改造
|
||||||
|
|
||||||
|
## 功能分析
|
||||||
|
|
||||||
|
这是基于已保存资料快照的双模型分析与医生复核工作台。主流程是确认患者和批次、查看模型完成情况、对照原方差异、核查资料缺口、记录所选模型的复核意见。
|
||||||
|
|
||||||
|
保留六个入口:对比总览、完整报告、候选与逐味、资料与缺口、处理进度、历史与趋势。历史批次切换、刷新、重新分析、单模型重试、独立复核草稿及保存继续使用现有服务和权限逻辑。医生原方及支持报告仍可访问。
|
||||||
|
|
||||||
|
## 设计决定
|
||||||
|
|
||||||
|
- 主程序 `shell.py` 将 prescriptions 等业务页列为科技蓝页面。因此继续复用 `reception_style.TECH_BLUE`:主色 #1769E8,背景 #F3F7FD,白色面板,分割线 #DBE5F2。没有采用主程序其他页面的靛蓝令牌,也没有修改全局主题。
|
||||||
|
- 深蓝渐变横幅改为白色标题工具栏。批次状态放在顶行,患者及原方信息单独成行,当前导航用浅蓝背景与蓝色底线标识。
|
||||||
|
- 缩小一致度圆环和数字,保留共同药味、候选药味与药味重合的计算依据;修复指标区样式误把分隔线应用到每个子标签的问题。
|
||||||
|
- 总览由三栏改成剂量差异主区与固定复核侧栏。三方交集和附件覆盖改为可展开的摘要,减少初始屏幕空白和对主图的挤压。
|
||||||
|
- 复核模型和复核状态并排显示,意见输入和保存按钮放在一起。顶部保存动作显示当前模型名,切换时同步更新。
|
||||||
|
- 短窗口收起次要信息,保留剂量主图和复核输入;展开图表时剂量滚动区域可让出高度。列表内容保持最小高度,避免刷新后条目重叠。
|
||||||
|
- 保留未知值与零值区分、不可比/历史状态处理、模型各自失败重试以及现有一致度说明。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 处方 AI 五组回归测试覆盖数据处理、权限、异步读取、逐味比较、页面、模型复核与布局。
|
||||||
|
- 新增八种窗口/展开状态组合的控件边界检查,以及展开/收起不改变比较数据和复核清单的检查。
|
||||||
|
- 离线模拟数据预览覆盖 1440×940、1280×860、1024×700、940×640,各内容页面、展开图表、运行中、失败、历史及统计窗口。
|
||||||
|
- 预览输出:`app/artifacts/issued_prescription_redesign/`。模拟数据仅供 UI 验证,不对应真实患者。
|
||||||
|
|
||||||
|
本次改动在现有未提交工作区基础上完成,仅调整桌面呈现与相关回归检查;没有重新打包或发布桌面安装程序。
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Render the prescription comparison window with synthetic, offline data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def example_batch() -> dict[str, Any]:
|
||||||
|
"""Three prescriptions that actually differ, so every state of the page has something to draw."""
|
||||||
|
|
||||||
|
doctor = {"生地黄": 16, "天花粉": 15, "干石斛": 20, "醋五味子": 6,
|
||||||
|
"生麦冬": 12, "茯苓": 10, "红参片": 6, "生牡丹皮": 10}
|
||||||
|
candidates = {
|
||||||
|
"qwen": {"生地黄": 15, "干石斛": 12, "醋五味子": 6, "生麦冬": 12, "茯苓": 15, "麸炒白术": 12, "丹参": 15},
|
||||||
|
"openai": {"生地黄": 12, "醋五味子": 6, "生麦冬": 10, "茯苓": 12, "生白芍": 5, "炒酸枣仁": 6},
|
||||||
|
}
|
||||||
|
reports = {
|
||||||
|
"qwen": {"summary": "界面演示数据:对照药味组成、剂量及用法,辅助医生逐项复核。",
|
||||||
|
"diagnosis": "消渴病,气阴两虚兼血热。兼证需结合舌脉资料核实。",
|
||||||
|
"analysis": "阅读药方对比页,查看同名药材的剂量差异、仅医生方药味与仅候选方药味。",
|
||||||
|
"risk_assessment": [{"level": "high", "label": "血压数据缺失,影响补气药安全性评估"},
|
||||||
|
{"level": "medium", "label": "肝肾功能具体指标未提供"}],
|
||||||
|
"missing_information": ["甲状腺功能及眼底检查报告缺失", "舌象与脉诊仅有附件,无文本记录"]},
|
||||||
|
"openai": {"summary": "界面演示数据:两份候选方独立生成,引用编号可回查原始资料。",
|
||||||
|
"diagnosis": "已记录气阴两虚证;辨证依据需补充。",
|
||||||
|
"analysis": "候选方以益气养阴为主,安神药味为本模型新增,需要医师确认。",
|
||||||
|
"risk_assessment": [{"level": "high", "label": "缺少当前用药记录,无法排除配伍风险"}],
|
||||||
|
"missing_information": ["舌象与脉诊仅有附件,无文本记录", "近期体重变化及 BMI 数据缺失"]},
|
||||||
|
}
|
||||||
|
models = {}
|
||||||
|
for key, doses in candidates.items():
|
||||||
|
herbs, rows = [], []
|
||||||
|
for name in sorted(set(doctor) | set(doses), key=lambda item: (item not in doses, item)):
|
||||||
|
common = {"name": name, "unit": "g", "dose_basis": "per_dose", "formula_type": "主方"}
|
||||||
|
left, right = doctor.get(name), doses.get(name)
|
||||||
|
if right is not None:
|
||||||
|
herbs.append({**common, "dosage": right})
|
||||||
|
rows.append({
|
||||||
|
**common, "key": name,
|
||||||
|
"doctor": {**common, "dosage": left} if left is not None else None,
|
||||||
|
"candidate": {**common, "dosage": right, "source_rows": [len(herbs) - 1]} if right is not None else None,
|
||||||
|
"doctor_dosage": left, "candidate_dosage": right,
|
||||||
|
"match_type": "candidate_only" if left is None else "doctor_only" if right is None else "matched",
|
||||||
|
"contribution": min(left, right) / max(left, right) if left and right else None,
|
||||||
|
})
|
||||||
|
matched = sum(row["match_type"] == "matched" for row in rows)
|
||||||
|
denominator = len(doctor) + len(herbs)
|
||||||
|
models[key] = {
|
||||||
|
"status": "success", "report_id": 10 if key == "qwen" else 11,
|
||||||
|
"candidate": {"status": "available_for_review", "prescription_name": "候选药方 · 界面示例",
|
||||||
|
"herbs": herbs, "dose_basis": "per_dose", "prescription_type": "浓缩水丸",
|
||||||
|
"usage_instruction": "服法由医生复核后确认。", "usage_days": 7, "times_per_day": 2,
|
||||||
|
"rationale": "这是用于检查界面排版的模拟药方,不对应真实患者。",
|
||||||
|
"risk_warnings": ["药味与剂量差异需要逐项复核。"]},
|
||||||
|
"comparison": {"status": "comparable",
|
||||||
|
"score": 200 * sum(row["contribution"] or 0 for row in rows) / denominator,
|
||||||
|
"herb_score": 200 * matched / denominator, "matched_count": matched,
|
||||||
|
"doctor_count": len(doctor), "candidate_count": len(herbs), "rows": rows,
|
||||||
|
"reason": "药味与剂量可比;服法与疗程需单独复核。", "usage_differences": []},
|
||||||
|
"report": reports[key],
|
||||||
|
"coverage": {"status": "incomplete", "complete": False,
|
||||||
|
"files": [{"file_id": f"a{index:04d}", "type": "image" if index % 3 else "document",
|
||||||
|
"status": "processed", "transmitted": True, "version_verified": True}
|
||||||
|
for index in range(20 if key == "qwen" else 17)]
|
||||||
|
+ [{"file_id": f"z{index:04d}", "type": "document", "status": "restricted",
|
||||||
|
"transmitted": False, "version_verified": False,
|
||||||
|
"reason": "FILE_UNAVAILABLE_OR_UNSUPPORTED"} for index in range(2)]},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 135 if key == "qwen" else 591,
|
||||||
|
"attempt": 1},
|
||||||
|
"usage": {"total_calls": 3, "calls": [
|
||||||
|
{"stage": "text:0", "ok": True, "latency_ms": 3120, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 980}, "error_code": ""},
|
||||||
|
{"stage": "files:0", "ok": True, "latency_ms": 22400,
|
||||||
|
"file_count": 20 if key == "qwen" else 17,
|
||||||
|
"usage": {"completion_tokens": 2140}, "error_code": ""},
|
||||||
|
{"stage": "final", "ok": True, "latency_ms": 18800, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 5617}, "error_code": ""}]},
|
||||||
|
"review": {"status": "viewed", "comment": ""},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||||
|
}
|
||||||
|
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391,
|
||||||
|
"prescription_revision": 1, "status": "success", "validity": "current",
|
||||||
|
"comparison_type": "non_independent", "models": models, "coverage_status": "partial",
|
||||||
|
"created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00",
|
||||||
|
"source_summary": {"diagnoses_count": 2, "attachment_count": 22, "video_calls_count": 4,
|
||||||
|
"chat_messages_count": 137, "source_record_count": 31},
|
||||||
|
"doctor_snapshot": {"patient": {"name": "张卫君", "gender": 2, "gender_label": "女", "age": 58},
|
||||||
|
"diagnosis": {"clinical_diagnosis": "2型糖尿病 消渴病 · 气阴两虚兼血热",
|
||||||
|
"chief_complaint": "咳嗽反复1月余"},
|
||||||
|
"prescription": {"herbs": [{"name": name, "dosage": dose, "unit": "g",
|
||||||
|
"dose_basis": "per_dose", "formula_type": "主方"}
|
||||||
|
for name, dose in doctor.items()],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1,
|
||||||
|
"usage_instruction": "每日1剂,水煎分服。"}},
|
||||||
|
"missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}]}
|
||||||
|
|
||||||
|
|
||||||
|
def example_statistics() -> dict[str, Any]:
|
||||||
|
"""Doctor-level shape the statistics window renders; synthetic, but structurally complete."""
|
||||||
|
|
||||||
|
def doctor(identifier: int, name: str, totals: tuple[int, int, int],
|
||||||
|
qwen: tuple[int, float | None], openai: tuple[int, float | None],
|
||||||
|
review: tuple[int, int]) -> dict[str, Any]:
|
||||||
|
total, patients, paired = totals
|
||||||
|
models = {}
|
||||||
|
for key, (eligible, mean) in (("qwen", qwen), ("openai", openai)):
|
||||||
|
share = (0.34, 0.38, 0.18, 0.07, 0.03)
|
||||||
|
models[key] = {"eligible_count": eligible, "mean": mean,
|
||||||
|
"median": None if mean is None else round(mean - 1.4, 1),
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": max(0, total - eligible - 2),
|
||||||
|
"incomplete_coverage": min(2, max(0, total - eligible))},
|
||||||
|
"distribution": {name: round(eligible * fraction)
|
||||||
|
for name, fraction in zip(
|
||||||
|
("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]"),
|
||||||
|
share, strict=True)}}
|
||||||
|
evaluated, qualified = review
|
||||||
|
return {"doctor_id": identifier, "doctor_name": name, "total_count": total,
|
||||||
|
"patient_count": patients, "paired_count": paired, "models": models,
|
||||||
|
"review": {"evaluated_count": evaluated, "qualified_count": qualified,
|
||||||
|
"qualified_rate": None if not evaluated else round(100 * qualified / evaluated, 1)}}
|
||||||
|
|
||||||
|
doctors = [doctor(26, "何医生", (18, 14, 9), (12, 21.4), (9, 18.9), (6, 4)),
|
||||||
|
doctor(31, "李医生", (11, 9, 4), (7, 26.8), (5, 24.1), (3, 1)),
|
||||||
|
doctor(44, "王医生", (6, 5, 1), (2, 15.2), (0, None), (0, 0))]
|
||||||
|
return {"total_count": sum(item["total_count"] for item in doctors),
|
||||||
|
"patient_count": sum(item["patient_count"] for item in doctors), "doctors": doctors}
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.batch = example_batch()
|
||||||
|
|
||||||
|
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||||
|
return {"enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
|
||||||
|
|
||||||
|
def history(self) -> list[dict[str, Any]]:
|
||||||
|
"""The current batch plus the earlier ones it supersedes, newest first."""
|
||||||
|
|
||||||
|
older = [
|
||||||
|
{"id": 39, "created_at": "2026-09-10 15:18:00", "status": "success", "validity": "superseded",
|
||||||
|
"comparison_type": "non_independent",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 48.9},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"},
|
||||||
|
"openai": {"comparison": {"status": "comparable", "score": 41.7},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}},
|
||||||
|
{"id": 38, "created_at": "2026-09-10 14:05:00", "status": "success", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 33.4},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"},
|
||||||
|
"openai": {"comparison": {"status": "comparable", "score": 31.1},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}},
|
||||||
|
{"id": 37, "created_at": "2026-09-10 13:25:00", "status": "failed", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"error_message": "模型返回未通过校验",
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1"},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||||
|
{"id": 36, "created_at": "2026-09-10 12:34:00", "status": "success", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "not_comparable"},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.0"},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||||
|
]
|
||||||
|
return [self.batch, *older]
|
||||||
|
|
||||||
|
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||||
|
return deepcopy(self.batch)
|
||||||
|
|
||||||
|
def prescription_ai_statistics(self, *_args: Any, **_params: Any) -> dict[str, Any]:
|
||||||
|
return deepcopy(example_statistics())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
output = Path(__file__).resolve().parents[1] / "artifacts" / "issued_prescription_redesign"
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def immediate(function: Any, **callbacks: Any) -> None:
|
||||||
|
result = function()
|
||||||
|
if callbacks.get("on_success"):
|
||||||
|
callbacks["on_success"](result)
|
||||||
|
if callbacks.get("on_finished"):
|
||||||
|
callbacks["on_finished"]()
|
||||||
|
|
||||||
|
ai.run_async = immediate
|
||||||
|
repository = PreviewRepository()
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
|
||||||
|
current_user={"name": "张医生"})
|
||||||
|
dialog.show()
|
||||||
|
names = {dialog.tabs.tabText(index): index for index in range(dialog.tabs.count())}
|
||||||
|
for filename, width, height, tab in (
|
||||||
|
("prescription-1440.png", 1440, 940, "对比总览"),
|
||||||
|
("prescription-1280.png", 1280, 860, "对比总览"),
|
||||||
|
("prescription-1024.png", 1024, 700, "对比总览"),
|
||||||
|
("prescription-940.png", 940, 640, "对比总览"),
|
||||||
|
("original-1280.png", 1280, 860, "原方记录"),
|
||||||
|
("analysis-1280.png", 1280, 860, "完整报告"),
|
||||||
|
("candidate-1280.png", 1280, 860, "方义与用法"),
|
||||||
|
("per-herb-1440.png", 1440, 940, "候选与逐味"),
|
||||||
|
("sources-1440.png", 1440, 940, "资料与缺口"),
|
||||||
|
("history-1440.png", 1440, 940, "历史与趋势"),
|
||||||
|
("pipeline-1440.png", 1440, 940, "处理进度"),
|
||||||
|
):
|
||||||
|
dialog.resize(width, height)
|
||||||
|
dialog.tabs.setCurrentIndex(names[tab])
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert (dialog.width(), dialog.height()) == (width, height), (filename, dialog.size())
|
||||||
|
assert dialog.grab().save(str(output / filename))
|
||||||
|
print(filename, "window", width, height, "workspace", dialog.tabs.width(), dialog.tabs.height())
|
||||||
|
dialog.tabs.setCurrentIndex(names["完整报告"])
|
||||||
|
dialog._set_report_mode("differences")
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "report-differences-1440.png"))
|
||||||
|
dialog._set_report_mode("both")
|
||||||
|
dialog.tabs.setCurrentIndex(names["对比总览"])
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.review_model.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "openai-1280.png"))
|
||||||
|
dialog.review_model.setCurrentIndex(0)
|
||||||
|
repository.batch["status"] = "running"
|
||||||
|
repository.batch["models"]["openai"].update(status="running", candidate=None, comparison=None, report=None)
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "partial-1280.png"))
|
||||||
|
repository.batch.update(validity="superseded", status="success")
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "historical-1280.png"))
|
||||||
|
repository.batch.update(validity="current", status="running")
|
||||||
|
repository.batch["models"]["qwen"].update(status="running", candidate=None, comparison=None, report=None)
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "pending-1280.png"))
|
||||||
|
# A failed model must state the reason and offer its own retry on the card itself.
|
||||||
|
repository.batch.update(validity="current", status="partial")
|
||||||
|
repository.batch["models"]["qwen"].update(
|
||||||
|
status="failed", error_code="upstream_timeout", error_message="上游模型超时,未返回结果",
|
||||||
|
candidate=None, comparison=None, report=None, retry_count=1, max_retry=3)
|
||||||
|
repository.batch["models"]["openai"] = deepcopy(example_batch()["models"]["openai"])
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "failed-1280.png"))
|
||||||
|
# The light theme is the same window with the other palette; capture it once.
|
||||||
|
dialog.resize(1440, 940)
|
||||||
|
dialog._switch_theme()
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.repaint()
|
||||||
|
assert dialog.grab().save(str(output / "light-1440.png"))
|
||||||
|
dialog._switch_theme()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||||
|
statistics.resize(1240, 880)
|
||||||
|
statistics.show()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert statistics.grab().save(str(output / "statistics-1240.png"))
|
||||||
|
statistics.close()
|
||||||
|
print("statistics-1240.png", statistics.panel.doctors.rowCount(), "doctors")
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||||
|
|
||||||
# Single source of truth for runtime, package, installer, and executable versions.
|
# Single source of truth for runtime, package, installer, and executable versions.
|
||||||
__version__ = "1.4.2"
|
__version__ = "1.4.5"
|
||||||
|
|
||||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ class ApplicationController(QObject):
|
|||||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||||
self.video_calls: dict[str, Any] = {}
|
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.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||||
self._video_preview_state: dict[str, Any] | None = None
|
self._video_preview_state: dict[str, Any] | None = None
|
||||||
self._video_preview_generation = 0
|
self._video_preview_generation = 0
|
||||||
@@ -726,6 +727,7 @@ class ApplicationController(QObject):
|
|||||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||||
self.video_calls.clear()
|
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():
|
for dialog in self.demo_video_dialogs.values():
|
||||||
dialog.close()
|
dialog.close()
|
||||||
self.demo_video_dialogs.clear()
|
self.demo_video_dialogs.clear()
|
||||||
@@ -747,6 +749,7 @@ class ApplicationController(QObject):
|
|||||||
return
|
return
|
||||||
patient_id = payload.get("patient_id")
|
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 "患者")
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||||
fallback_record = payload.get("record")
|
fallback_record = payload.get("record")
|
||||||
@@ -754,7 +757,15 @@ class ApplicationController(QObject):
|
|||||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||||
return
|
return
|
||||||
|
|
||||||
call_key = str(diagnosis_id)
|
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)
|
existing_call = self.video_calls.get(call_key)
|
||||||
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
||||||
qt_window = getattr(existing_call, "qt_window", None)
|
qt_window = getattr(existing_call, "qt_window", None)
|
||||||
@@ -802,12 +813,17 @@ class ApplicationController(QObject):
|
|||||||
repository = self.current_repository
|
repository = self.current_repository
|
||||||
marker = object()
|
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]]:
|
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||||
ticket = repository.get_call_ticket(
|
ticket = repository.get_call_ticket(
|
||||||
patient_id=int(patient_id),
|
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: Any = {}
|
||||||
detail_loader = getattr(repository, "patient_detail", None)
|
detail_loader = getattr(repository, "patient_detail", None)
|
||||||
if callable(detail_loader):
|
if callable(detail_loader):
|
||||||
@@ -871,6 +887,8 @@ class ApplicationController(QObject):
|
|||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
self.video_pending.pop(call_key, None)
|
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:
|
if self.shell_window is parent:
|
||||||
show_toast(
|
show_toast(
|
||||||
parent,
|
parent,
|
||||||
@@ -895,6 +913,8 @@ class ApplicationController(QObject):
|
|||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
self.video_pending.pop(call_key, None)
|
self.video_pending.pop(call_key, None)
|
||||||
|
if self._pending_im_request == (call_key, marker):
|
||||||
|
self._pending_im_request = None
|
||||||
if (
|
if (
|
||||||
self.shell_window is None
|
self.shell_window is None
|
||||||
or self.current_repository is not repository
|
or self.current_repository is not repository
|
||||||
@@ -919,8 +939,8 @@ class ApplicationController(QObject):
|
|||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
patient_case=patient_case,
|
patient_case=patient_case,
|
||||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
|
||||||
self._open_video_diagnosis(current_id)
|
self._open_video_diagnosis(current_id, call_key=current_key)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as error:
|
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."""
|
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||||
|
|
||||||
shell = self.shell_window
|
shell = self.shell_window
|
||||||
@@ -951,7 +971,7 @@ class ApplicationController(QObject):
|
|||||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||||
if dialog is None:
|
if dialog is None:
|
||||||
return
|
return
|
||||||
call = self.video_calls.get(str(diagnosis_id))
|
call = self.video_calls.get(call_key)
|
||||||
video_window = getattr(call, "qt_window", None)
|
video_window = getattr(call, "qt_window", None)
|
||||||
if video_window is not None:
|
if video_window is not None:
|
||||||
self._show_video_preview(video_window, dialog)
|
self._show_video_preview(video_window, dialog)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Appointment medium labels, separate from first-visit/follow-up diagnosis type."""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APPOINTMENT_MODES = (("视频问诊", "video"), ("图文问诊", "text"))
|
||||||
|
|
||||||
|
|
||||||
|
def appointment_type_description(value: Any) -> str:
|
||||||
|
if value is None or isinstance(value, str) and not value.strip():
|
||||||
|
return "视频问诊"
|
||||||
|
return {"video": "视频问诊", "text": "图文问诊", "phone": "电话问诊"}.get(str(value), "未知")
|
||||||
|
|
||||||
|
|
||||||
|
def can_appointment_video(value: Any) -> bool:
|
||||||
|
"""UI hint only; actual call permission must come from the server ticket."""
|
||||||
|
return value is None or isinstance(value, str) and (not value.strip() or value == "video")
|
||||||
|
|
||||||
|
|
||||||
|
def appointment_type_value(record: Any) -> Any:
|
||||||
|
"""Read appointment rows and diagnosis-list projections without visit-type aliases."""
|
||||||
|
def read(value: Any, key: str) -> Any:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value.get(key)
|
||||||
|
result = getattr(value, key, None)
|
||||||
|
raw = getattr(value, "raw", None)
|
||||||
|
return raw.get(key) if result is None and isinstance(raw, Mapping) else result
|
||||||
|
|
||||||
|
appointment_id = read(record, "appointment_id") or read(record, "latest_appointment_id")
|
||||||
|
rows = read(record, "appointments")
|
||||||
|
if isinstance(rows, (list, tuple)) and rows:
|
||||||
|
if appointment_id:
|
||||||
|
for row in rows:
|
||||||
|
if str(read(row, "id")) == str(appointment_id):
|
||||||
|
return read(row, "appointment_type")
|
||||||
|
elif len(rows) == 1:
|
||||||
|
return read(rows[0], "appointment_type")
|
||||||
|
else:
|
||||||
|
return "unknown"
|
||||||
|
for key in ("appointment_type", "latest_appointment_type"):
|
||||||
|
value = read(record, key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
return value
|
||||||
|
for key in ("appointment", "latest_appointment"):
|
||||||
|
nested = read(record, key)
|
||||||
|
if nested is not None:
|
||||||
|
return read(nested, "appointment_type")
|
||||||
|
return None
|
||||||
@@ -3022,7 +3022,7 @@ class DemoDoctorRepository:
|
|||||||
raise ValueError("order_no is required")
|
raise ValueError("order_no is required")
|
||||||
return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"}
|
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."""
|
"""Return non-production placeholder credentials for UI demonstration."""
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -3036,21 +3036,22 @@ class DemoDoctorRepository:
|
|||||||
assistant_id="assistant_2001",
|
assistant_id="assistant_2001",
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
is_lochost_vod=False,
|
is_lochost_vod=False,
|
||||||
raw={"demo": True},
|
raw={"demo": True, "appointment_id": appointment_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_call(
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Create a mutable demo call record."""
|
"""Create a mutable demo call record."""
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.get_call_ticket(patient_id, diagnosis_id)
|
self.get_call_ticket(patient_id, diagnosis_id, appointment_id=appointment_id)
|
||||||
record = {
|
record = {
|
||||||
"id": self._next_call_id,
|
"id": self._next_call_id,
|
||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"call_type": call_type,
|
"call_type": call_type,
|
||||||
|
"appointment_id": appointment_id,
|
||||||
"status": "ringing",
|
"status": "ringing",
|
||||||
"room_id": "",
|
"room_id": "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,27 @@ class DoctorRepository(Protocol):
|
|||||||
def get_prescription(self, prescription_id: int) -> Prescription:
|
def get_prescription(self, prescription_id: int) -> Prescription:
|
||||||
"""Return one issued prescription."""
|
"""Return one issued prescription."""
|
||||||
|
|
||||||
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
|
"""Read cached dual-model states for up to 100 prescriptions."""
|
||||||
|
|
||||||
|
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||||
|
"""Read immutable analysis batch history in the authorized scope."""
|
||||||
|
|
||||||
|
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||||
|
"""Read one analysis batch without starting model work."""
|
||||||
|
|
||||||
|
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
|
||||||
|
"""Explicitly request a new analysis batch with a reason."""
|
||||||
|
|
||||||
|
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
|
||||||
|
"""Retry only the requested failed model."""
|
||||||
|
|
||||||
|
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
|
||||||
|
"""Save a doctor's review separately from immutable AI output."""
|
||||||
|
|
||||||
|
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
|
||||||
|
"""Read baseline agreement statistics, never diagnostic accuracy."""
|
||||||
|
|
||||||
def create_prescription(
|
def create_prescription(
|
||||||
self,
|
self,
|
||||||
prescription: Prescription | Mapping[str, Any] | None = None,
|
prescription: Prescription | Mapping[str, Any] | None = None,
|
||||||
@@ -712,10 +733,10 @@ class DoctorRepository(Protocol):
|
|||||||
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
|
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
|
||||||
"""Generate the payment mini-program QR code for a generic order."""
|
"""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."""
|
"""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."""
|
"""Create a call record."""
|
||||||
|
|
||||||
def end_call(
|
def end_call(
|
||||||
@@ -1583,6 +1604,47 @@ class RemoteDoctorRepository:
|
|||||||
)
|
)
|
||||||
return Prescription.from_dict(result)
|
return Prescription.from_dict(result)
|
||||||
|
|
||||||
|
def _prescription_ai_request(self, action: str, params: dict[str, Any], *, mutation: bool = False) -> dict[str, Any]:
|
||||||
|
endpoint = f"tcm.prescriptionAi/{action}"
|
||||||
|
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
||||||
|
return dict(_require_mapping(payload, endpoint))
|
||||||
|
|
||||||
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
|
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
||||||
|
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
||||||
|
return self._prescription_ai_request("statuses", {"ids": ",".join(str(int(value)) for value in dict.fromkeys(ids))})
|
||||||
|
|
||||||
|
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||||
|
if bool(prescription_id) == bool(diagnosis_id):
|
||||||
|
raise ValueError("Exactly one prescription_id or diagnosis_id is required")
|
||||||
|
params = {"prescription_id": prescription_id} if prescription_id else {"diagnosis_id": diagnosis_id}
|
||||||
|
params.update(page_no=page_no, page_size=page_size)
|
||||||
|
return self._prescription_ai_request("reports", params)
|
||||||
|
|
||||||
|
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||||
|
return self._prescription_ai_request("detail", {"batch_id": batch_id})
|
||||||
|
|
||||||
|
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
|
||||||
|
if not reason.strip():
|
||||||
|
raise ValueError("重新分析需要填写原因")
|
||||||
|
return self._prescription_ai_request("regenerate", {"prescription_id": prescription_id, "reason": reason.strip()}, mutation=True)
|
||||||
|
|
||||||
|
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
|
||||||
|
if model_key not in {"qwen", "openai"}:
|
||||||
|
raise ValueError("Unknown model_key")
|
||||||
|
return self._prescription_ai_request("retry", {"batch_id": batch_id, "model_key": model_key}, mutation=True)
|
||||||
|
|
||||||
|
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
|
||||||
|
if model_key not in {"qwen", "openai"} or status not in {"viewed", "needs_information", "not_adopted", "reviewed"}:
|
||||||
|
raise ValueError("Invalid review state")
|
||||||
|
return self._prescription_ai_request("review", {"batch_id": batch_id, "model_key": model_key, "status": status, "comment": comment}, mutation=True)
|
||||||
|
|
||||||
|
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {"date_from": date_from, "date_to": date_to}
|
||||||
|
if doctor_id is not None:
|
||||||
|
params["doctor_id"] = doctor_id
|
||||||
|
return self._prescription_ai_request("statistics", params)
|
||||||
|
|
||||||
def create_prescription(
|
def create_prescription(
|
||||||
self,
|
self,
|
||||||
prescription: Prescription | Mapping[str, Any] | None = None,
|
prescription: Prescription | Mapping[str, Any] | None = None,
|
||||||
@@ -2629,22 +2691,51 @@ class RemoteDoctorRepository:
|
|||||||
)
|
)
|
||||||
return result
|
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."""
|
"""Obtain short-lived Tencent credentials for a consultation call."""
|
||||||
|
|
||||||
result = _require_mapping(
|
result = _require_mapping(
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"tcm.diagnosis/getCallSignature",
|
"tcm.diagnosis/getCallSignature",
|
||||||
{"patient_id": patient_id, "diagnosis_id": diagnosis_id},
|
{"patient_id": patient_id, "diagnosis_id": diagnosis_id, "appointment_id": appointment_id},
|
||||||
),
|
),
|
||||||
"tcm.diagnosis/getCallSignature",
|
"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)
|
ticket = CallTicket.from_dict(result)
|
||||||
if ticket.diagnosis_id is None:
|
if ticket.diagnosis_id is None:
|
||||||
ticket.diagnosis_id = diagnosis_id
|
ticket.diagnosis_id = diagnosis_id
|
||||||
return ticket
|
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."""
|
"""Create the server-side call record before ringing participants."""
|
||||||
|
|
||||||
payload = self.client.post(
|
payload = self.client.post(
|
||||||
@@ -2653,6 +2744,7 @@ class RemoteDoctorRepository:
|
|||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"call_type": call_type,
|
"call_type": call_type,
|
||||||
|
"appointment_id": appointment_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -975,10 +976,21 @@ class AppointmentDrawer(QDialog):
|
|||||||
self.appointment_type_radio = QRadioButton("视频问诊")
|
self.appointment_type_radio = QRadioButton("视频问诊")
|
||||||
self.appointment_type_radio.setChecked(True)
|
self.appointment_type_radio.setChecked(True)
|
||||||
type_layout.addWidget(self.appointment_type_radio)
|
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)
|
type_layout.addStretch(1)
|
||||||
self._add_form_row("预约类型:", type_container)
|
self._add_form_row("预约类型:", type_container)
|
||||||
self.appointment_type = QComboBox(self)
|
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()
|
self.appointment_type.hide()
|
||||||
|
|
||||||
patient_container = QWidget()
|
patient_container = QWidget()
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ from PySide6.QtWidgets import (
|
|||||||
QWidgetItem,
|
QWidgetItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..core.appointment_modes import (
|
||||||
|
appointment_type_description,
|
||||||
|
appointment_type_value,
|
||||||
|
can_appointment_video,
|
||||||
|
)
|
||||||
from . import icons
|
from . import icons
|
||||||
from .reception_style import TECH_BLUE, body_family, heading_family
|
from .reception_style import TECH_BLUE, body_family, heading_family
|
||||||
from .widgets import display_text, first_value, gender_text, get_value
|
from .widgets import display_text, first_value, gender_text, get_value
|
||||||
@@ -112,7 +117,7 @@ def _blue_appointment_text(record: Any, appointment: Any) -> tuple[str, str]:
|
|||||||
This returns display strings without mutating either repository record.
|
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))
|
appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0))
|
||||||
current = current_id > 0 and appointment_id == current_id
|
current = current_id > 0 and appointment_id == current_id
|
||||||
doctor = first_value(appointment, "doctor_name", "appointment_doctor_name")
|
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(
|
when = time_text if date_text and date_text in time_text else " ".join(
|
||||||
part for part in (date_text, time_text) if part
|
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
|
return display_text(doctor, "—"), when
|
||||||
|
|
||||||
|
|
||||||
@@ -298,6 +308,7 @@ def _appointments(record: Any) -> list[Any]:
|
|||||||
{
|
{
|
||||||
"id": first_value(record, "appointment_id"),
|
"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(
|
"doctor_name": first_value(
|
||||||
record, "appointment_doctor_name", "doctor_name", default=""
|
record, "appointment_doctor_name", "doctor_name", default=""
|
||||||
),
|
),
|
||||||
@@ -393,6 +404,7 @@ def _video_ids_complete(record: Any) -> bool:
|
|||||||
first_value(
|
first_value(
|
||||||
record,
|
record,
|
||||||
"appointment_id",
|
"appointment_id",
|
||||||
|
"latest_appointment_id",
|
||||||
default=first_value(
|
default=first_value(
|
||||||
_appointments(record)[0] if _appointments(record) else None,
|
_appointments(record)[0] if _appointments(record) else None,
|
||||||
"id",
|
"id",
|
||||||
@@ -771,6 +783,7 @@ class DiagnosisTableModel(QAbstractTableModel):
|
|||||||
for part in (
|
for part in (
|
||||||
display_text(first_value(apt, "doctor_name"), "-"),
|
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
|
if part
|
||||||
)
|
)
|
||||||
@@ -1776,12 +1789,14 @@ class DiagnosisTableHost(QFrame):
|
|||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
video_capable = self.action_policy.get("video_call", False)
|
video_capable = self.action_policy.get("video_call", False)
|
||||||
call_state = video_call_state(record)
|
call_state = video_call_state(record)
|
||||||
if video_capable and _appointment_active(record) and call_state == "live":
|
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 = QToolButton(host)
|
||||||
button.setText("进入视频问诊")
|
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||||
button.setProperty("rowLink", "primary")
|
button.setProperty("rowLink", "primary")
|
||||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
button.setToolTip("发送文字、图片和文件;不支持音视频通话" if is_text
|
||||||
|
else "医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||||
button.setEnabled(_video_ids_complete(record))
|
button.setEnabled(_video_ids_complete(record))
|
||||||
if not button.isEnabled():
|
if not button.isEnabled():
|
||||||
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
||||||
@@ -1900,7 +1915,8 @@ class DiagnosisTableHost(QFrame):
|
|||||||
"cancel_assign", item
|
"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(
|
_add_menu_action(
|
||||||
menu,
|
menu,
|
||||||
"视频二维码",
|
"视频二维码",
|
||||||
|
|||||||
@@ -5729,6 +5729,7 @@ class AiConsultDialog(QDialog):
|
|||||||
"usage_notes",
|
"usage_notes",
|
||||||
}
|
}
|
||||||
seed.update({key: deepcopy(value) for key, value in draft.items() if key in allowed})
|
seed.update({key: deepcopy(value) for key, value in draft.items() if key in allowed})
|
||||||
|
seed["ai_assisted"] = True
|
||||||
dialog = PrescriptionEditorDialog(
|
dialog = PrescriptionEditorDialog(
|
||||||
self.repository,
|
self.repository,
|
||||||
seed,
|
seed,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||||
from ..diagnosis_drawer import (
|
from ..diagnosis_drawer import (
|
||||||
DIAGNOSIS_QSS,
|
DIAGNOSIS_QSS,
|
||||||
CaseGrid,
|
CaseGrid,
|
||||||
@@ -77,6 +78,7 @@ from ..widgets import (
|
|||||||
page_total,
|
page_total,
|
||||||
run_async,
|
run_async,
|
||||||
)
|
)
|
||||||
|
from .issued_prescription_ai import can_open_issued_ai, present_issued_prescription_ai
|
||||||
from .local_audio_queue import LocalAudioQueueDialog
|
from .local_audio_queue import LocalAudioQueueDialog
|
||||||
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
|
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
|
||||||
|
|
||||||
@@ -1066,6 +1068,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.readonly_ai_button.clicked.connect(self._open_ai_report)
|
self.readonly_ai_button.clicked.connect(self._open_ai_report)
|
||||||
heading_row.addWidget(self.readonly_ai_button, 0)
|
heading_row.addWidget(self.readonly_ai_button, 0)
|
||||||
|
self.readonly_prescription_ai_button = QPushButton("处方 AI 对照", card)
|
||||||
|
self.readonly_prescription_ai_button.setProperty("variant", "secondary")
|
||||||
|
self.readonly_prescription_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
|
||||||
|
self.readonly_prescription_ai_button.clicked.connect(self._open_prescription_ai_report)
|
||||||
|
heading_row.addWidget(self.readonly_prescription_ai_button, 0)
|
||||||
layout.addLayout(heading_row)
|
layout.addLayout(heading_row)
|
||||||
patient_hero = QFrame()
|
patient_hero = QFrame()
|
||||||
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
|
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
|
||||||
@@ -1708,6 +1716,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
if hasattr(self, "readonly_ai_button"):
|
if hasattr(self, "readonly_ai_button"):
|
||||||
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
||||||
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
||||||
|
if hasattr(self, "readonly_prescription_ai_button"):
|
||||||
|
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
|
||||||
|
self.readonly_prescription_ai_button.setEnabled(self._diagnosis_id > 0)
|
||||||
previous_key = self._current_tab_key()
|
previous_key = self._current_tab_key()
|
||||||
allowed_tabs = [
|
allowed_tabs = [
|
||||||
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
||||||
@@ -1959,6 +1970,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
||||||
|
|
||||||
|
def _open_prescription_ai_report(self) -> None:
|
||||||
|
if self._diagnosis_id > 0:
|
||||||
|
present_issued_prescription_ai(self.repository, self.permissions, self, diagnosis_id=self._diagnosis_id)
|
||||||
|
|
||||||
def open_view_only(
|
def open_view_only(
|
||||||
self,
|
self,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
@@ -3589,7 +3604,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
first_value(row, "assistant_name"),
|
first_value(row, "assistant_name"),
|
||||||
first_value(row, "appointment_date"),
|
first_value(row, "appointment_date"),
|
||||||
first_value(row, "appointment_time", "period"),
|
first_value(row, "appointment_time", "period"),
|
||||||
first_value(row, "appointment_type_text", "appointment_type"),
|
appointment_type_description(appointment_type_value(row)),
|
||||||
" / ".join(
|
" / ".join(
|
||||||
part
|
part
|
||||||
for part in (
|
for part in (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
|||||||
|
"""Painted charts for the prescription analysis window, in the workstation's tech blue.
|
||||||
|
|
||||||
|
Every widget draws only what the saved report contains: a value that is missing stays visibly
|
||||||
|
absent instead of being drawn as zero, and no chart implies a medical judgement by colour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
|
||||||
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
|
||||||
|
|
||||||
|
# Colour names resolve through the active palette at paint time, so a theme switch needs no
|
||||||
|
# rebuild here: the next repaint already draws in the new colours.
|
||||||
|
|
||||||
|
|
||||||
|
class _Palette:
|
||||||
|
"""Attribute access into the live palette: ``COLOUR.qwen`` is always the current hue."""
|
||||||
|
|
||||||
|
__slots__ = ("_keys",)
|
||||||
|
|
||||||
|
def __init__(self, **keys: str) -> None:
|
||||||
|
object.__setattr__(self, "_keys", dict(keys))
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> str:
|
||||||
|
return TECH_BLUE[self._keys[name]]
|
||||||
|
|
||||||
|
|
||||||
|
COLOUR = _Palette(doctor="muted", qwen="qwen", openai="openai", track="raised",
|
||||||
|
limited="amber", ink="heading", muted="muted")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: Any) -> int:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
number = int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return max(0, number)
|
||||||
|
|
||||||
|
|
||||||
|
class DonutGauge(QWidget):
|
||||||
|
"""A ring reading one percentage. An unavailable value leaves the track empty, never zero."""
|
||||||
|
|
||||||
|
def __init__(self, accent: str, parent: QWidget | None = None, *, diameter: int = 88,
|
||||||
|
thickness: int = 11, track: str = COLOUR.track) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._accent, self._track = accent, track
|
||||||
|
self._thickness = thickness
|
||||||
|
self._value: float | None = None
|
||||||
|
self.setFixedSize(diameter, diameter)
|
||||||
|
self.setAccessibleName("一致度环形图")
|
||||||
|
self.set_value(None)
|
||||||
|
|
||||||
|
def set_value(self, value: Any) -> None:
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = None if value is None or isinstance(value, bool) else float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = None
|
||||||
|
self._value = None if parsed is None or parsed < 0 or parsed > 100 else parsed
|
||||||
|
self.setAccessibleDescription("暂无可比结果" if self._value is None else f"{self._value:.1f}%")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def value(self) -> float | None:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
inset = self._thickness / 2 + 1
|
||||||
|
box = QRectF(inset, inset, self.width() - 2 * inset, self.height() - 2 * inset)
|
||||||
|
pen = painter.pen()
|
||||||
|
pen.setWidthF(self._thickness)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
||||||
|
pen.setColor(QColor(self._track))
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawEllipse(box)
|
||||||
|
if self._value:
|
||||||
|
pen.setColor(QColor(self._accent))
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
# Qt angles are sixteenths of a degree; start at twelve o'clock and run clockwise.
|
||||||
|
painter.drawArc(box, 90 * 16, -int(360 * 16 * self._value / 100))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class VennChart(QWidget):
|
||||||
|
"""Doctor / model-A / model-B herb sets with their real intersection counts."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._sets: dict[str, int] = {}
|
||||||
|
self._labels = ("医生原方", "千问", "OpenAI")
|
||||||
|
self.setMinimumHeight(96)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
|
def set_counts(self, counts: Mapping[str, int], labels: tuple[str, str, str] | None = None) -> None:
|
||||||
|
"""Regions: doctor_only, qwen_only, openai_only, doctor_qwen, doctor_openai, qwen_openai, all."""
|
||||||
|
|
||||||
|
self._sets = {key: _count(value) for key, value in _mapping(counts).items()}
|
||||||
|
if labels:
|
||||||
|
self._labels = labels
|
||||||
|
total = sum(self._sets.values())
|
||||||
|
self.setAccessibleDescription(
|
||||||
|
"三方用药交集:" + " · ".join(f"{key} {value}" for key, value in self._sets.items()) if total
|
||||||
|
else "尚无可比较的候选药方")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return any(self._sets.values())
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self.has_data():
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# The three set names sit above and below the circles, so their bands are reserved first.
|
||||||
|
top_band, bottom_band = 18.0, 18.0
|
||||||
|
available = max(40.0, self.height() - top_band - bottom_band)
|
||||||
|
side = min(self.width() * 0.62, available / 1.34)
|
||||||
|
radius = side / 2
|
||||||
|
offset = radius * 0.52
|
||||||
|
centre_x = self.width() / 2
|
||||||
|
centre_y = top_band + available / 2 - offset * 0.1
|
||||||
|
circles = (
|
||||||
|
(centre_x - offset, centre_y - offset * 0.55, COLOUR.doctor),
|
||||||
|
(centre_x + offset, centre_y - offset * 0.55, COLOUR.qwen),
|
||||||
|
(centre_x, centre_y + offset * 0.75, COLOUR.openai),
|
||||||
|
)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
for x, y, colour in circles:
|
||||||
|
fill = QColor(colour)
|
||||||
|
fill.setAlpha(52)
|
||||||
|
painter.setBrush(fill)
|
||||||
|
painter.drawEllipse(QPointF(x, y), radius, radius)
|
||||||
|
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(13)
|
||||||
|
font.setBold(True)
|
||||||
|
painter.setFont(font)
|
||||||
|
regions = (
|
||||||
|
("doctor_only", centre_x - offset * 1.5, centre_y - offset * 0.75, COLOUR.doctor),
|
||||||
|
("qwen_only", centre_x + offset * 1.5, centre_y - offset * 0.75, COLOUR.qwen),
|
||||||
|
("openai_only", centre_x, centre_y + offset * 1.45, COLOUR.openai),
|
||||||
|
("doctor_qwen", centre_x, centre_y - offset * 0.95, COLOUR.ink),
|
||||||
|
("doctor_openai", centre_x - offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
|
||||||
|
("qwen_openai", centre_x + offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
|
||||||
|
("all", centre_x, centre_y + offset * 0.1, COLOUR.ink),
|
||||||
|
)
|
||||||
|
for key, x, y, colour in regions:
|
||||||
|
value = self._sets.get(key, 0)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
painter.setPen(QColor(colour))
|
||||||
|
painter.drawText(QRectF(x - 22, y - 10, 44, 20), Qt.AlignmentFlag.AlignCenter, str(value))
|
||||||
|
|
||||||
|
font.setPixelSize(11)
|
||||||
|
font.setBold(False)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
top = centre_y - offset * 0.55 - radius - 17
|
||||||
|
painter.setPen(QColor(COLOUR.doctor))
|
||||||
|
painter.drawText(QRectF(0, top, centre_x - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._labels[0])
|
||||||
|
painter.setPen(QColor(COLOUR.qwen))
|
||||||
|
painter.drawText(QRectF(centre_x + 6, top, centre_x - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._labels[1])
|
||||||
|
painter.setPen(QColor(COLOUR.openai))
|
||||||
|
painter.drawText(QRectF(0, centre_y + offset * 0.75 + radius + 1, self.width(), 16),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, self._labels[2])
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(200, 96)
|
||||||
|
|
||||||
|
|
||||||
|
class DivergingDoses(QWidget):
|
||||||
|
"""Per-herb dose difference against the doctor's prescription, one row per herb."""
|
||||||
|
|
||||||
|
ROW = 30
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[dict[str, Any]] = []
|
||||||
|
self._span = 1.0
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_rows(self, rows: list[Mapping[str, Any]]) -> None:
|
||||||
|
"""Each row: name, doctor, qwen, openai (floats or None), unit."""
|
||||||
|
|
||||||
|
self._rows = []
|
||||||
|
for row in rows:
|
||||||
|
value = _mapping(row)
|
||||||
|
entry = {"name": str(value.get("name") or ""), "unit": str(value.get("unit") or "")}
|
||||||
|
for key in ("doctor", "qwen", "openai"):
|
||||||
|
raw = value.get(key)
|
||||||
|
entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
|
||||||
|
self._rows.append(entry)
|
||||||
|
deltas = [abs((row[key] or 0) - (row["doctor"] or 0))
|
||||||
|
for row in self._rows for key in ("qwen", "openai")
|
||||||
|
if row[key] is not None and row["doctor"] is not None]
|
||||||
|
self._span = max(1.0, max(deltas, default=1.0))
|
||||||
|
self.setAccessibleDescription("剂量差异:" + " · ".join(
|
||||||
|
f"{row['name']} 千问 {self._delta_text(row, 'qwen')} OpenAI {self._delta_text(row, 'openai')}"
|
||||||
|
for row in self._rows) if self._rows else "暂无可比药味")
|
||||||
|
self.setMinimumHeight(self.ROW * max(1, len(self._rows)) + 18)
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _delta_text(self, row: Mapping[str, Any], key: str) -> str:
|
||||||
|
if row.get(key) is None or row.get("doctor") is None:
|
||||||
|
return "—"
|
||||||
|
delta = row[key] - row["doctor"]
|
||||||
|
return "一致" if abs(delta) < 1e-9 else f"{delta:+g}{row.get('unit') or ''}"
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._rows:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
label_width, value_width = 96, 96
|
||||||
|
left = label_width + 10
|
||||||
|
right = self.width() - value_width - 10
|
||||||
|
middle = (left + right) / 2
|
||||||
|
scale = max(1.0, (right - left) / 2) / self._span
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(12)
|
||||||
|
painter.setFont(font)
|
||||||
|
for index, row in enumerate(self._rows):
|
||||||
|
top = index * self.ROW + 4
|
||||||
|
painter.setPen(QColor(COLOUR.ink))
|
||||||
|
painter.drawText(QRectF(0, top, label_width, self.ROW - 8),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, row["name"])
|
||||||
|
painter.setPen(QColor(COLOUR.track))
|
||||||
|
painter.drawLine(QPointF(middle, top), QPointF(middle, top + self.ROW - 10))
|
||||||
|
for offset, key, colour in ((0, "qwen", COLOUR.qwen), (10, "openai", COLOUR.openai)):
|
||||||
|
if row[key] is None or row["doctor"] is None:
|
||||||
|
continue
|
||||||
|
delta = (row[key] - row["doctor"]) * scale
|
||||||
|
bar = QRectF(min(middle, middle + delta), top + offset, max(abs(delta), 2.0), 8)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(bar, 3, 3)
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(right + 8, top, value_width - 8, self.ROW - 8),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
f"{self._delta_text(row, 'qwen')} / {self._delta_text(row, 'openai')}")
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class WaffleCoverage(QWidget):
|
||||||
|
"""One square per attachment: read, limited, or not delivered."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None, *, columns: int = 11) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._columns = max(4, columns)
|
||||||
|
self._read = self._limited = self._total = 0
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_counts(self, read: int, limited: int, total: int | None = None) -> None:
|
||||||
|
self._read, self._limited = _count(read), _count(limited)
|
||||||
|
self._total = max(_count(total), self._read + self._limited)
|
||||||
|
self.setAccessibleDescription(
|
||||||
|
f"附件 {self._total} 个:已读 {self._read},受限或不支持 {self._limited}" if self._total else "本次没有附件")
|
||||||
|
rows = max(1, -(-self._total // self._columns)) if self._total else 0
|
||||||
|
self.setMinimumHeight(rows * 16 + max(0, rows - 1) * 4)
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return self._total > 0
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._total:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
gap = 4
|
||||||
|
size = max(8.0, min(16.0, (self.width() - gap * (self._columns - 1)) / self._columns))
|
||||||
|
for index in range(self._total):
|
||||||
|
column, row = index % self._columns, index // self._columns
|
||||||
|
colour = COLOUR.qwen if index < self._read else (COLOUR.limited if index < self._read + self._limited else COLOUR.track)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(QRectF(column * (size + gap), row * (size + gap), size, size), 4, 4)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class TrendBars(QWidget):
|
||||||
|
"""Grouped bars per batch: one column per model, oldest batch first."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._points: list[dict[str, Any]] = []
|
||||||
|
self.setMinimumHeight(170)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_points(self, points: list[Mapping[str, Any]]) -> None:
|
||||||
|
"""Each point: label plus qwen/openai scores; None keeps the slot visibly empty."""
|
||||||
|
|
||||||
|
self._points = []
|
||||||
|
for point in points:
|
||||||
|
value = _mapping(point)
|
||||||
|
entry = {"label": str(value.get("label") or "")}
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
raw = value.get(key)
|
||||||
|
entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
|
||||||
|
self._points.append(entry)
|
||||||
|
described = [f"{entry['label']} 千问 {self._text(entry['qwen'])} OpenAI {self._text(entry['openai'])}"
|
||||||
|
for entry in self._points]
|
||||||
|
self.setAccessibleDescription("一致度趋势:" + (" · ".join(described) or "暂无批次"))
|
||||||
|
self.setToolTip("\n".join(described) or "暂无批次")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text(value: float | None) -> str:
|
||||||
|
return "—" if value is None else f"{value:.1f}%"
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return any(point.get(key) is not None for point in self._points for key in ("qwen", "openai"))
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._points:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# The top band is left free so each bar can print its own figure above it.
|
||||||
|
left, right, top, bottom = 44.0, self.width() - 10.0, 24.0, self.height() - 24.0
|
||||||
|
values = [point[key] for point in self._points for key in ("qwen", "openai") if point[key] is not None]
|
||||||
|
ceiling = max(10.0, max(values, default=10.0))
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(10)
|
||||||
|
painter.setFont(font)
|
||||||
|
for fraction in (0.0, 0.5, 1.0):
|
||||||
|
y = bottom - (bottom - top) * fraction
|
||||||
|
painter.setPen(QColor(COLOUR.track))
|
||||||
|
painter.drawLine(QPointF(left, y), QPointF(right, y))
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(0, y - 8, left - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
f"{ceiling * fraction:.0f}%")
|
||||||
|
slot = (right - left) / max(1, len(self._points))
|
||||||
|
width = min(26.0, slot / 3.6)
|
||||||
|
# The two bars of a batch sit side by side, far enough apart for each figure to fit above.
|
||||||
|
gap = width / 2 + 4
|
||||||
|
for index, point in enumerate(self._points):
|
||||||
|
centre = left + slot * (index + 0.5)
|
||||||
|
for offset, key, colour in ((-gap, "qwen", COLOUR.qwen), (gap, "openai", COLOUR.openai)):
|
||||||
|
value = point[key]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
height = (bottom - top) * min(1.0, value / ceiling)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3)
|
||||||
|
# The design prints each figure above its bar, in that model's own colour.
|
||||||
|
painter.setPen(QColor(colour))
|
||||||
|
painter.drawText(QRectF(centre + offset - 21, bottom - height - 17, 42, 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, f"{value:.1f}%")
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(centre - slot / 2, bottom + 4, slot, 16),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, point["label"])
|
||||||
|
painter.end()
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
"""Native, read-only prescription comparison using saved report snapshots only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QButtonGroup,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issued_prescription_ai_labels import EXTRA_FIELD_LABELS, SYSTEM_LABELS, system_text
|
||||||
|
from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES
|
||||||
|
|
||||||
|
MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
|
||||||
|
MODEL_COLORS = {"qwen": "#3676C8", "openai": "#268578"}
|
||||||
|
DOCTOR_COLOR = "#526479"
|
||||||
|
INK, MUTED, LINE = "#23384C", "#758395", "#E6EDF3"
|
||||||
|
_UNITS = {
|
||||||
|
"g": "克", "克": "克", "mg": "毫克", "毫克": "毫克", "kg": "千克", "千克": "千克",
|
||||||
|
"ml": "毫升", "毫升": "毫升", "l": "升", "升": "升", "iu": "国际单位", "国际单位": "国际单位",
|
||||||
|
**{unit: unit for unit in ("片", "丸", "粒", "袋", "包", "支", "滴", "枚", "个", "条", "付", "帖", "两", "钱")},
|
||||||
|
}
|
||||||
|
_BASES = {"per_dose": "每剂", "每剂": "每剂", "per_day": "每日", "每日": "每日", "每天": "每日"}
|
||||||
|
_FORMULAS = {"main": "主方", "1": "主方", "主方": "主方", "aux": "辅方", "auxiliary": "辅方", "2": "辅方", "辅方": "辅方"}
|
||||||
|
_STALE = {
|
||||||
|
"stale": "处方已变更", "superseded": "处方已变更", "prescription_changed": "处方已变更",
|
||||||
|
"source_updated": "资料已更新", "invalid": "报告已失效", "voided": "处方已作废",
|
||||||
|
"deleted": "处方已删除", "revoked": "资料权限已变更",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
"""Never stringify a structured payload, which could expose internal identifiers."""
|
||||||
|
return str(value).strip() if isinstance(value, (str, int, float, Decimal)) and not isinstance(value, bool) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reason(value: Any) -> str:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return _reason(value.get("reason") or value.get("message") or value.get("code"))
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ";".join(filter(None, (_reason(item) for item in value)))
|
||||||
|
return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> Decimal | None:
|
||||||
|
if not _text(value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number.is_finite() and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _formula(value: Any) -> str:
|
||||||
|
return _FORMULAS.get(_text(value), "主辅方未注明")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(value: Any) -> str:
|
||||||
|
return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _join_instructions(values: list[str]) -> str:
|
||||||
|
parts = [part.strip() for value in values for part in value.split(";")]
|
||||||
|
return ";".join(dict.fromkeys(part for part in parts if part and part.lower() not in {"无", "明确无", "none"}))
|
||||||
|
|
||||||
|
|
||||||
|
def _instructions(snapshot: dict[str, Any]) -> str:
|
||||||
|
"""Read both original herb fields and the service's normalized usage snapshot."""
|
||||||
|
fields = ("instructions", "decoction_instruction", "special_usage", "usage_instruction", "usage_time", "usage_way")
|
||||||
|
usage = _mapping(snapshot.get("usage"))
|
||||||
|
values = [_text(owner.get(field)) for owner in (snapshot, usage) for field in fields]
|
||||||
|
if not usage:
|
||||||
|
values.append(_text(snapshot.get("usage")))
|
||||||
|
return _join_instructions(values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Dose:
|
||||||
|
raw: Any
|
||||||
|
unit: str
|
||||||
|
basis: str
|
||||||
|
formula: str
|
||||||
|
processing: str
|
||||||
|
route: str
|
||||||
|
group: str
|
||||||
|
instructions: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self) -> Decimal | None:
|
||||||
|
return _number(self.raw)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self) -> tuple[str, str] | None:
|
||||||
|
unit, basis = _UNITS.get(self.unit.lower()), _BASES.get(self.basis)
|
||||||
|
return (unit, basis) if unit and basis else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
amount = _text(self.raw)
|
||||||
|
if not amount:
|
||||||
|
return "—"
|
||||||
|
unit = _UNITS.get(self.unit.lower()) or _metadata(self.unit) or "单位未注明"
|
||||||
|
basis = _BASES.get(self.basis) or "基准未确认"
|
||||||
|
return f"{amount} {unit} / {basis}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> tuple[str, str, str, str]:
|
||||||
|
# Unknown enum values must remain distinct even when their display label is generic.
|
||||||
|
return (_FORMULAS.get(self.formula, self.formula), SYSTEM_LABELS.get(self.processing, self.processing), SYSTEM_LABELS.get(self.route, self.route), self.group)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Row:
|
||||||
|
name: str
|
||||||
|
context: str
|
||||||
|
doctor: _Dose | None
|
||||||
|
candidate: _Dose | None
|
||||||
|
match_type: str
|
||||||
|
origin: str = "comparison"
|
||||||
|
source_details: str = ""
|
||||||
|
source_names: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self) -> tuple[str, str] | None:
|
||||||
|
if self.origin != "comparison":
|
||||||
|
return None
|
||||||
|
doses = [dose for dose in (self.doctor, self.candidate) if dose is not None]
|
||||||
|
if not doses or any(dose.scale is None for dose in doses):
|
||||||
|
return None
|
||||||
|
if len({dose.scale for dose in doses}) != 1 or len({dose.identity for dose in doses}) != 1:
|
||||||
|
return None
|
||||||
|
return doses[0].scale
|
||||||
|
|
||||||
|
@property
|
||||||
|
def incompatibility(self) -> str:
|
||||||
|
if self.origin == "uncompared":
|
||||||
|
return "未纳入对比,仅保留候选原方;医生剂量未知"
|
||||||
|
if self.origin == "original":
|
||||||
|
return "候选原方附列;对应关系未保存,不推断同药"
|
||||||
|
if self.doctor is not None and self.candidate is not None:
|
||||||
|
if self.doctor.identity != self.candidate.identity:
|
||||||
|
return "主辅方、炮制或给药分组不同,不作条形比较"
|
||||||
|
if self.doctor.scale and self.candidate.scale and self.doctor.scale != self.candidate.scale:
|
||||||
|
return "单位或剂量基准不同,不作条形比较"
|
||||||
|
return "单位或每剂/每日基准未明确,不作条形比较"
|
||||||
|
|
||||||
|
def usage_description(self, model_name: str) -> str:
|
||||||
|
return ";".join(filter(None, ("医生:" + self.doctor.instructions if self.doctor and self.doctor.instructions else "", model_name + ":" + self.candidate.instructions if self.candidate and self.candidate.instructions else "")))
|
||||||
|
|
||||||
|
def description(self, model_name: str) -> str:
|
||||||
|
dosage = f"{self.name},{self.context};医生:{self.doctor.label if self.doctor else '—'};{model_name}:{self.candidate.label if self.candidate else '—'}"
|
||||||
|
return ";".join(filter(None, (dosage, self.usage_description(model_name), self.source_details)))
|
||||||
|
|
||||||
|
|
||||||
|
def _dose(row: dict[str, Any], side: str) -> _Dose | None:
|
||||||
|
# An explicit null snapshot takes precedence over contradictory legacy flat fields.
|
||||||
|
if side in row and row[side] is None:
|
||||||
|
return None
|
||||||
|
nested = _mapping(row.get(side))
|
||||||
|
keys = ("doctor_dosage", "doctor_dose") if side == "doctor" else ("candidate_dosage", "candidate_dose", "ai_dose")
|
||||||
|
raw = nested.get("dosage") if "dosage" in nested else next((row[key] for key in keys if key in row), None)
|
||||||
|
if not nested and raw is None:
|
||||||
|
return None
|
||||||
|
values = {key: _text(nested[key] if key in nested else row.get(key)) for key in ("unit", "dose_basis", "formula_type", "processing", "administration_route", "group")}
|
||||||
|
instructions = _instructions(nested) or _instructions(row)
|
||||||
|
return _Dose(raw, values["unit"], values["dose_basis"], values["formula_type"], values["processing"], values["administration_route"], values["group"], instructions)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(value: dict[str, Any]) -> _Row:
|
||||||
|
doctor, candidate = _dose(value, "doctor"), _dose(value, "candidate")
|
||||||
|
details = []
|
||||||
|
for dose in (doctor, candidate):
|
||||||
|
if dose is not None:
|
||||||
|
detail = " · ".join(filter(None, (_formula(dose.formula), _metadata(dose.processing), _metadata(dose.route), _metadata(dose.group))))
|
||||||
|
if detail not in details:
|
||||||
|
details.append(detail)
|
||||||
|
context = " / ".join(details) or _formula(value.get("formula_type"))
|
||||||
|
if len(details) > 1:
|
||||||
|
context = "医生与模型:" + context
|
||||||
|
return _Row(_text(value.get("name") or value.get("canonical_name") or value.get("herb_name")) or "药名未保存", context, doctor, candidate, _text(value.get("match_type")))
|
||||||
|
|
||||||
|
|
||||||
|
def _original_row(herb: Any, *, origin: str) -> _Row:
|
||||||
|
saved = dict(herb) if isinstance(herb, Mapping) else {"name": _text(herb) or "药材记录需核对"}
|
||||||
|
row = _row({"name": saved.get("name"), "doctor": None, "candidate": saved, "match_type": "candidate_only"})
|
||||||
|
note = "未纳入对比" if origin == "uncompared" else "候选原方附列 · 对应关系未保存"
|
||||||
|
return replace(row, origin=origin, context=note + " · " + row.context)
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_rows(candidate: dict[str, Any], comparison: dict[str, Any]) -> list[_Row]:
|
||||||
|
saved = comparison.get("rows")
|
||||||
|
comparison_rows = [dict(value) for value in saved if isinstance(value, Mapping)] if isinstance(saved, list) else []
|
||||||
|
herbs = candidate.get("herbs")
|
||||||
|
herbs = herbs if isinstance(herbs, list) else []
|
||||||
|
result: list[_Row] = []
|
||||||
|
covered: set[int] = set()
|
||||||
|
missing_correspondence = False
|
||||||
|
for value in comparison_rows:
|
||||||
|
row = _row(value)
|
||||||
|
if row.candidate is not None and herbs:
|
||||||
|
indices = _mapping(value.get("candidate")).get("source_rows")
|
||||||
|
# The service uses zero-based array_values indices; never infer correspondence
|
||||||
|
# from names, doses, order, or the number of normalized rows.
|
||||||
|
valid_trace = isinstance(indices, list) and bool(indices) and all(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(herbs) for index in indices)
|
||||||
|
if valid_trace:
|
||||||
|
indices = list(dict.fromkeys(indices))
|
||||||
|
covered.update(indices)
|
||||||
|
originals = [_original_row(herbs[index], origin="original") for index in indices]
|
||||||
|
details = ";".join(f"第 {index + 1} 项 {original.name} {original.candidate.label if original.candidate else '—'}" + ("," + original.candidate.instructions if original.candidate and original.candidate.instructions else "") for index, original in zip(indices, originals, strict=True))
|
||||||
|
instructions = _join_instructions([row.candidate.instructions, *[original.candidate.instructions for original in originals if original.candidate]])
|
||||||
|
row = replace(row, candidate=replace(row.candidate, instructions=instructions), source_details="候选原方记录:" + details, source_names=tuple(original.name for original in originals))
|
||||||
|
else:
|
||||||
|
missing_correspondence = True
|
||||||
|
result.append(row)
|
||||||
|
# Normalization can omit unknown names, processing conflicts, or incompatible duplicate
|
||||||
|
# entries. Keep every unaccounted original, but do not claim its doctor counterpart.
|
||||||
|
origin = "original" if missing_correspondence else "uncompared"
|
||||||
|
result.extend(_original_row(herb, origin=origin) for index, herb in enumerate(herbs) if index not in covered)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class _DoseChart(QWidget):
|
||||||
|
"""A scrollable painted chart; the adjacent table provides native accessibility."""
|
||||||
|
|
||||||
|
ROW_HEIGHT = 80
|
||||||
|
GROUP_HEIGHT = 38
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("PrescriptionDoseChart")
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
self.setAccessibleName("药方逐味剂量对比图")
|
||||||
|
self.rows: list[_Row] = []
|
||||||
|
self.groups: list[tuple[tuple[str, str] | None, list[_Row], Decimal]] = []
|
||||||
|
self.model_key = "qwen"
|
||||||
|
self.message = "暂无药方数据"
|
||||||
|
self.bars_enabled = False
|
||||||
|
|
||||||
|
def set_rows(self, rows: list[_Row], model_key: str, *, bars_enabled: bool, message: str) -> None:
|
||||||
|
self.rows, self.model_key, self.bars_enabled, self.message = rows, model_key, bars_enabled, message
|
||||||
|
grouped: dict[tuple[str, str] | None, list[_Row]] = {}
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row.scale if bars_enabled else None, []).append(row)
|
||||||
|
self.groups = []
|
||||||
|
for scale, members in grouped.items():
|
||||||
|
numbers = [dose.number for row in members for dose in (row.doctor, row.candidate) if dose is not None and dose.number is not None]
|
||||||
|
self.groups.append((scale, members, max(numbers, default=Decimal(0))))
|
||||||
|
color_name = "蓝色" if model_key == "qwen" else "青绿色"
|
||||||
|
descriptions = [message, f"医生为深灰蓝;{MODEL_NAMES[model_key]}为{color_name}。各单位与基准组独立标尺,组间长度不可比较。缺失值为—,不按零计算。"]
|
||||||
|
for row in rows:
|
||||||
|
descriptions.append(row.description(MODEL_NAMES[model_key]))
|
||||||
|
if bars_enabled and row.scale is None:
|
||||||
|
descriptions.append(row.incompatibility)
|
||||||
|
if any(dose is not None and _text(dose.raw) and dose.number is None for dose in (row.doctor, row.candidate)):
|
||||||
|
descriptions.append("非数值、负数及非有限剂量保留原值,未绘制条形。")
|
||||||
|
self.setAccessibleDescription("\n".join(descriptions))
|
||||||
|
height = 16 + sum(self.GROUP_HEIGHT + len(members) * self.ROW_HEIGHT for _, members, _ in self.groups)
|
||||||
|
self.setMinimumHeight(max(120, height))
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize:
|
||||||
|
return QSize(440, self.minimumHeight())
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(0, 0)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None:
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.fillRect(self.rect(), QColor("#FFFFFF"))
|
||||||
|
painter.setFont(self.font())
|
||||||
|
if not self.rows:
|
||||||
|
painter.setPen(QColor(MUTED))
|
||||||
|
painter.drawText(self.rect().adjusted(24, 12, -24, -12), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.message)
|
||||||
|
return
|
||||||
|
width, y = max(0, self.width() - 32), 8
|
||||||
|
normal = QFont(self.font())
|
||||||
|
bold = QFont(normal)
|
||||||
|
bold.setBold(True)
|
||||||
|
for scale, members, maximum in self.groups:
|
||||||
|
painter.fillRect(QRectF(16, y, width, self.GROUP_HEIGHT - 8), QColor("#F3F6F9"))
|
||||||
|
painter.setFont(bold)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
heading = f"{scale[0]} · {scale[1]} 独立标尺 0–{maximum}" if scale else "原始剂量 · 不作条形比较"
|
||||||
|
painter.drawText(QRectF(24, y, max(0, width - 16), self.GROUP_HEIGHT - 8), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(heading, Qt.TextElideMode.ElideRight, max(0, width - 16)))
|
||||||
|
y += self.GROUP_HEIGHT
|
||||||
|
for row in members:
|
||||||
|
if y + self.ROW_HEIGHT >= event.rect().top() and y <= event.rect().bottom():
|
||||||
|
painter.setFont(bold)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
title = f"{row.name} · {row.context}"
|
||||||
|
painter.drawText(QRectF(16, y, width, 21), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(title, Qt.TextElideMode.ElideRight, width))
|
||||||
|
painter.setFont(normal)
|
||||||
|
if scale is None:
|
||||||
|
painter.setPen(QColor(MUTED))
|
||||||
|
detail = ";".join(("医生 " + (row.doctor.label if row.doctor else "—"), MODEL_NAMES[self.model_key] + " " + (row.candidate.label if row.candidate else "—")))
|
||||||
|
painter.drawText(QRectF(16, y + 24, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(detail, Qt.TextElideMode.ElideRight, width))
|
||||||
|
note = row.incompatibility if self.bars_enabled else "按已保存原值列示,详见左侧药材表"
|
||||||
|
painter.drawText(QRectF(16, y + 49, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(note, Qt.TextElideMode.ElideRight, width))
|
||||||
|
else:
|
||||||
|
for index, (dose, color, name) in enumerate(((row.doctor, DOCTOR_COLOR, "医生"), (row.candidate, MODEL_COLORS[self.model_key], MODEL_NAMES[self.model_key]))):
|
||||||
|
bar_y = y + 24 + index * 20
|
||||||
|
painter.setPen(QColor(color))
|
||||||
|
painter.drawText(QRectF(16, bar_y - 5, 54, 22), Qt.AlignmentFlag.AlignVCenter, name)
|
||||||
|
value = dose.label if dose else "—"
|
||||||
|
label_width = min(max(118, painter.fontMetrics().horizontalAdvance(value) + 8), max(118, width // 2))
|
||||||
|
bar_x, bar_width = 76, max(8, width - 66 - label_width - 12)
|
||||||
|
if dose is not None and dose.number is not None:
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#F0F3F7"))
|
||||||
|
painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width, 9), 3, 3)
|
||||||
|
fraction = float(dose.number / maximum) if maximum else 0.0
|
||||||
|
if fraction > 0:
|
||||||
|
painter.setBrush(QColor(color))
|
||||||
|
painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width * fraction, 9), 3, 3)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
painter.drawText(QRectF(bar_x + bar_width + 10, bar_y - 5, label_width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, int(label_width)))
|
||||||
|
painter.setPen(QColor(LINE))
|
||||||
|
painter.drawLine(16, y + self.ROW_HEIGHT - 6, self.width() - 16, y + self.ROW_HEIGHT - 6)
|
||||||
|
y += self.ROW_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
class PrescriptionComparisonPanel(QWidget):
|
||||||
|
"""Switch between saved candidate prescriptions without issuing any requests."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("PrescriptionComparisonPanel")
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self._batch: dict[str, Any] = {}
|
||||||
|
self._rows: list[_Row] = []
|
||||||
|
self._model_key = "qwen"
|
||||||
|
self._bars_enabled = False
|
||||||
|
self._chart_message = "暂无药方数据"
|
||||||
|
self._candidate_count: int | None = None
|
||||||
|
self._updating_rows = False
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QWidget#PrescriptionComparisonPanel {{ background: transparent; }}
|
||||||
|
QFrame#PrescriptionPane {{ background: white; border: 1px solid {LINE}; border-radius: 9px; }}
|
||||||
|
QLabel {{ color: {INK}; background: transparent; }}
|
||||||
|
QLabel#PrescriptionTitle {{ font-size: 20px; font-weight: 700; }}
|
||||||
|
QLabel#PrescriptionCaption {{ color: {MUTED}; font-size: 12px; }}
|
||||||
|
QLabel#PrescriptionStatus {{ color: #5F7287; font-size: 12px; }}
|
||||||
|
QPushButton#PrescriptionModel {{ border: 1px solid #DAE4EE; background: white; color: #607388;
|
||||||
|
border-radius: 6px; padding: 6px 19px; font-weight: 600; }}
|
||||||
|
QPushButton#PrescriptionModel[model="qwen"]:checked {{ background: #EDF4FE; color: #3676C8; border-color: #AAC7EB; }}
|
||||||
|
QPushButton#PrescriptionModel[model="openai"]:checked {{ background: #EDF7F4; color: #268578; border-color: #A5D2C6; }}
|
||||||
|
QLineEdit#PrescriptionSearch {{ background: white; border: 1px solid #DAE4EE; border-radius: 6px; padding: 6px 10px; }}
|
||||||
|
QTableWidget#PrescriptionHerbs {{ background: white; border: 0; color: {INK}; gridline-color: {LINE}; }}
|
||||||
|
QTableWidget#PrescriptionHerbs::item {{ padding: 4px 7px; border-bottom: 1px solid #EDF1F5; }}
|
||||||
|
QTableWidget#PrescriptionHerbs::item:selected {{ background: #EDF4FC; color: {INK}; }}
|
||||||
|
QHeaderView::section {{ background: #F4F7FA; color: #687C91; border: 0; padding: 7px; font-weight: 600; }}
|
||||||
|
QScrollArea {{ background: white; border: 0; }}
|
||||||
|
""")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
toolbar.setSpacing(7)
|
||||||
|
self.model_buttons: dict[str, QPushButton] = {}
|
||||||
|
self.model_group = QButtonGroup(self)
|
||||||
|
for model_key, name in MODEL_NAMES.items():
|
||||||
|
button = QPushButton(name, self)
|
||||||
|
button.setObjectName("PrescriptionModel")
|
||||||
|
button.setProperty("model", model_key)
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setChecked(model_key == self._model_key)
|
||||||
|
button.setAccessibleName(f"查看{name}候选方与医生方对比")
|
||||||
|
button.clicked.connect(lambda _checked=False, key=model_key: self._select_model(key))
|
||||||
|
self.model_group.addButton(button)
|
||||||
|
self.model_buttons[model_key] = button
|
||||||
|
toolbar.addWidget(button)
|
||||||
|
toolbar.addStretch(1)
|
||||||
|
self.search = QLineEdit(self)
|
||||||
|
self.search.setObjectName("PrescriptionSearch")
|
||||||
|
self.search.setPlaceholderText("搜索药名")
|
||||||
|
self.search.setAccessibleName("搜索药名,同时筛选药材表和图表")
|
||||||
|
self.search.setClearButtonEnabled(True)
|
||||||
|
self.search.setMaximumWidth(240)
|
||||||
|
self.search.textChanged.connect(self._filter_rows)
|
||||||
|
toolbar.addWidget(self.search)
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
panes = QHBoxLayout()
|
||||||
|
panes.setSpacing(12)
|
||||||
|
left = QFrame(self)
|
||||||
|
left.setObjectName("PrescriptionPane")
|
||||||
|
left.setMinimumWidth(0)
|
||||||
|
left.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
left_layout = QVBoxLayout(left)
|
||||||
|
left_layout.setContentsMargins(15, 13, 15, 10)
|
||||||
|
left_layout.setSpacing(6)
|
||||||
|
self.caption_label = self._label("千问 · 候选药方", left, "PrescriptionCaption")
|
||||||
|
self.name_label = self._label("暂无候选药方", left, "PrescriptionTitle")
|
||||||
|
self.name_label.setWordWrap(True)
|
||||||
|
self.name_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
self.usage_label = self._label("", left, "PrescriptionCaption")
|
||||||
|
self.usage_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.caption_label)
|
||||||
|
left_layout.addWidget(self.name_label)
|
||||||
|
left_layout.addWidget(self.usage_label)
|
||||||
|
self.herb_table = QTableWidget(0, 3, left)
|
||||||
|
self.herb_table.setObjectName("PrescriptionHerbs")
|
||||||
|
self.herb_table.setAccessibleName("候选药方药材与医生剂量对照表")
|
||||||
|
self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", "千问剂量"])
|
||||||
|
self.herb_table.verticalHeader().hide()
|
||||||
|
self.herb_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.herb_table.horizontalHeader().setMinimumSectionSize(36)
|
||||||
|
self.herb_table.setShowGrid(False)
|
||||||
|
self.herb_table.setWordWrap(True)
|
||||||
|
self.herb_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.herb_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.herb_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self.herb_table.itemSelectionChanged.connect(self._focus_chart_row)
|
||||||
|
self.herb_table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||||
|
self.herb_table.setMinimumSize(0, 0)
|
||||||
|
self.herb_table.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
left_layout.addWidget(self.herb_table, 1)
|
||||||
|
self.empty_label = self._label("选择报告后显示已保存的候选药方", left, "PrescriptionCaption")
|
||||||
|
self.empty_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.empty_label)
|
||||||
|
self.count_label = self._label("", left, "PrescriptionCaption")
|
||||||
|
self.count_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.count_label)
|
||||||
|
panes.addWidget(left, 5)
|
||||||
|
right = QFrame(self)
|
||||||
|
right.setObjectName("PrescriptionPane")
|
||||||
|
right.setMinimumWidth(0)
|
||||||
|
right.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.setContentsMargins(15, 13, 15, 10)
|
||||||
|
right_layout.setSpacing(6)
|
||||||
|
title = self._label("逐味剂量对比", right)
|
||||||
|
title.setStyleSheet("font-size: 15px; font-weight: 700;")
|
||||||
|
legend_row = QHBoxLayout()
|
||||||
|
legend_row.addWidget(title)
|
||||||
|
legend_row.addStretch()
|
||||||
|
self.legend = self._label("● 医生 ● 千问", right, "PrescriptionCaption")
|
||||||
|
self.legend.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
legend_row.addWidget(self.legend)
|
||||||
|
right_layout.addLayout(legend_row)
|
||||||
|
self.status_label = self._label("", right, "PrescriptionStatus")
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
right_layout.addWidget(self.status_label)
|
||||||
|
self.chart_scroll = QScrollArea(right)
|
||||||
|
self.chart_scroll.setWidgetResizable(True)
|
||||||
|
self.chart_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.chart_scroll.setMinimumSize(0, 0)
|
||||||
|
self.chart_scroll.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
self.chart_scroll.setAccessibleName("可滚动查看全部药味的剂量图")
|
||||||
|
self.chart = _DoseChart(self.chart_scroll)
|
||||||
|
self.chart_scroll.setWidget(self.chart)
|
||||||
|
right_layout.addWidget(self.chart_scroll, 1)
|
||||||
|
footnote = self._label("同组同标尺,组间勿比较;剂量差异不代表医疗优劣。", right, "PrescriptionCaption")
|
||||||
|
footnote.setWordWrap(True)
|
||||||
|
right_layout.addWidget(footnote)
|
||||||
|
panes.addWidget(right, 5)
|
||||||
|
layout.addLayout(panes, 1)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label(text: str, parent: QWidget, name: str = "") -> QLabel:
|
||||||
|
label = QLabel(text, parent)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setObjectName(name)
|
||||||
|
return label
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_model(self) -> str:
|
||||||
|
return self._model_key
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(0, 0)
|
||||||
|
|
||||||
|
def set_batch(self, batch: dict) -> None:
|
||||||
|
data = _mapping(batch)
|
||||||
|
if data == self._batch:
|
||||||
|
return
|
||||||
|
self._batch = deepcopy(data)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _select_model(self, model_key: str) -> None:
|
||||||
|
if model_key == self._model_key:
|
||||||
|
return
|
||||||
|
self._model_key = model_key
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _state(self, model: dict[str, Any], candidate: dict[str, Any], comparison: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
if not self._batch:
|
||||||
|
return False, "选择一份报告后,查看已保存的候选药方与剂量对比。"
|
||||||
|
validity = _text(self._batch.get("validity"))
|
||||||
|
if validity and validity not in {"current", "valid"}:
|
||||||
|
return False, (_STALE.get(validity) or "报告有效性未确认") + ";仅查看历史原值,暂停条形比较。"
|
||||||
|
status = _text(model.get("status"))
|
||||||
|
candidate_status = _text(candidate.get("status"))
|
||||||
|
if status in ACTIVE_STATES or candidate_status in ACTIVE_STATES:
|
||||||
|
return False, f"{MODEL_NAMES[self._model_key]}正在生成候选方,完成后显示剂量对比。"
|
||||||
|
if status in _STALE:
|
||||||
|
return False, _STALE[status] + ";仅查看历史原值。"
|
||||||
|
if status in {"failed", "cancelled", "canceled", "blocked"}:
|
||||||
|
return False, "候选方分析未完成;已保存内容仅供查看。"
|
||||||
|
if candidate_status in {"insufficient_data", "withheld_for_risk", "no_medication", "no_medication_recommended"}:
|
||||||
|
headline = {"insufficient_data": "资料不足,暂未提供候选药方", "withheld_for_risk": "因风险暂缓候选用药", "no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物"}[candidate_status]
|
||||||
|
return False, headline + (";" + _reason(candidate.get("reason")) if candidate.get("reason") else "。")
|
||||||
|
if status and status not in SUCCESS_STATES | {"partial"}:
|
||||||
|
return False, "模型状态未确认;仅列示已保存原值。"
|
||||||
|
if candidate_status and candidate_status not in {"available_for_review", "success", "succeeded", "completed"}:
|
||||||
|
return False, "候选方状态未确认;仅列示已保存原值。"
|
||||||
|
if comparison.get("status") == "not_comparable":
|
||||||
|
return False, "本报告不可比:" + (_reason(comparison.get("reason") or comparison.get("reason_code")) or "未通过单位、剂量或资料完整性核验。")
|
||||||
|
saved_rows = comparison.get("rows")
|
||||||
|
if not isinstance(saved_rows, list) or not any(isinstance(row, Mapping) for row in saved_rows):
|
||||||
|
return False, "尚无已保存的逐味对比;医生剂量以—表示,不推算历史处方。"
|
||||||
|
if comparison.get("status") != "comparable":
|
||||||
|
return False, "可比状态未确认;仅列示已保存原值。"
|
||||||
|
prefix = "报告有效性未注明;" if not validity else ""
|
||||||
|
return True, prefix + "按明确单位与每剂/每日基准分组;缺失剂量不按零计算。"
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
model = _mapping(_mapping(self._batch.get("models")).get(self._model_key))
|
||||||
|
candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison"))
|
||||||
|
self._rows = _saved_rows(candidate, comparison)
|
||||||
|
self._bars_enabled, self._chart_message = self._state(model, candidate, comparison)
|
||||||
|
name = MODEL_NAMES[self._model_key]
|
||||||
|
self.caption_label.setText(f"{name} · 候选药方")
|
||||||
|
candidate_herbs = candidate.get("herbs")
|
||||||
|
self._candidate_count = len(candidate_herbs) if isinstance(candidate_herbs, list) else None
|
||||||
|
has_herbs = isinstance(candidate_herbs, list) and bool(candidate_herbs)
|
||||||
|
if any(row.origin == "original" for row in self._rows):
|
||||||
|
self._chart_message += " 候选原方另行附列;历史对应关系未保存,不推断同药。"
|
||||||
|
elif comparison.get("rows") and any(row.origin == "uncompared" for row in self._rows):
|
||||||
|
self._chart_message += " 未纳入对比的候选药材已补列原值。"
|
||||||
|
empty_title = "候选方生成中" if model.get("status") in ACTIVE_STATES else "暂无候选药方"
|
||||||
|
self.name_label.setText(_text(candidate.get("prescription_name")) or ("已保存候选方" if has_herbs else empty_title))
|
||||||
|
self.name_label.setToolTip(self.name_label.text())
|
||||||
|
usage = []
|
||||||
|
if _text(candidate.get("usage_instruction")):
|
||||||
|
usage.append(_text(candidate["usage_instruction"]))
|
||||||
|
if _number(candidate.get("times_per_day")) is not None:
|
||||||
|
usage.append(f"每日 {candidate['times_per_day']} 次")
|
||||||
|
if _number(candidate.get("usage_days")) is not None:
|
||||||
|
usage.append(f"共 {candidate['usage_days']} 天")
|
||||||
|
usage_text = " · ".join(usage)
|
||||||
|
self.usage_label.setText(usage_text[:100] + ("…" if len(usage_text) > 100 else ""))
|
||||||
|
self.usage_label.setToolTip(usage_text)
|
||||||
|
self.usage_label.setVisible(bool(usage_text))
|
||||||
|
self.status_label.setText(self._chart_message)
|
||||||
|
self.legend.setText(f'<span style="color:{DOCTOR_COLOR}">● 医生</span> <span style="color:{MODEL_COLORS[self._model_key]}">● {name}</span>')
|
||||||
|
self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", f"{name}剂量"])
|
||||||
|
self._filter_rows()
|
||||||
|
|
||||||
|
def _filter_rows(self) -> None:
|
||||||
|
self._updating_rows = True
|
||||||
|
query = self.search.text().strip().casefold()
|
||||||
|
rows = [row for row in self._rows if any(query in name.casefold() for name in (row.name, *row.source_names))]
|
||||||
|
table_scroll = self.herb_table.verticalScrollBar().value()
|
||||||
|
chart_scroll = self.chart_scroll.verticalScrollBar().value()
|
||||||
|
selected_row = self.herb_table.currentRow()
|
||||||
|
selected_name = self.herb_table.item(selected_row, 0).text() if selected_row >= 0 and self.herb_table.item(selected_row, 0) else ""
|
||||||
|
self.herb_table.setRowCount(len(rows))
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
name_text = row.name + "\n" + row.context
|
||||||
|
instructions = row.usage_description(MODEL_NAMES[self._model_key])
|
||||||
|
if instructions:
|
||||||
|
name_text += "\n" + instructions
|
||||||
|
values = (name_text, row.doctor.label if row.doctor else "—", row.candidate.label if row.candidate else "—")
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
item = QTableWidgetItem(value)
|
||||||
|
item.setToolTip(row.description(MODEL_NAMES[self._model_key]))
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, value)
|
||||||
|
if column == 0:
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleDescriptionRole, row.description(MODEL_NAMES[self._model_key]))
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
if column == 1:
|
||||||
|
item.setForeground(QColor(DOCTOR_COLOR))
|
||||||
|
if column == 2:
|
||||||
|
item.setForeground(QColor(MODEL_COLORS[self._model_key]))
|
||||||
|
self.herb_table.setItem(index, column, item)
|
||||||
|
self.herb_table.setRowHeight(index, max(48, self.herb_table.fontMetrics().height() * (3 if instructions else 2) + 12))
|
||||||
|
if name_text == selected_name:
|
||||||
|
self.herb_table.selectRow(index)
|
||||||
|
self.herb_table.verticalScrollBar().setValue(table_scroll)
|
||||||
|
self.herb_table.setVisible(bool(rows))
|
||||||
|
self.empty_label.setText("没有匹配的药名,请调整搜索。" if self._rows and not rows else self._chart_message)
|
||||||
|
self.empty_label.setVisible(not rows)
|
||||||
|
comparison_count = sum(row.origin == "comparison" for row in self._rows)
|
||||||
|
uncompared_count = sum(row.origin == "uncompared" for row in self._rows)
|
||||||
|
original_count = sum(row.origin == "original" for row in self._rows)
|
||||||
|
counts = [f"候选原方 {self._candidate_count} 项" if self._candidate_count is not None else "候选原方未保存", f"对比 {comparison_count} 项"]
|
||||||
|
if uncompared_count:
|
||||||
|
counts.append(f"未纳入 {uncompared_count} 项")
|
||||||
|
if original_count:
|
||||||
|
counts.append(f"原方附列 {original_count} 项")
|
||||||
|
if query:
|
||||||
|
counts.append(f"搜索显示 {len(rows)} 项")
|
||||||
|
self.count_label.setText(" · ".join(counts))
|
||||||
|
self.count_label.setToolTip("原方项数来自候选药材完整清单;对比项数来自已保存的标准化记录。附列药材不推断与对比记录的对应关系;— 表示该侧未保存剂量。")
|
||||||
|
self.count_label.setVisible(bool(self._rows))
|
||||||
|
empty_message = "没有匹配的药名" if self._rows and not rows else self._chart_message
|
||||||
|
self.chart.set_rows(rows, self._model_key, bars_enabled=self._bars_enabled, message=empty_message)
|
||||||
|
self.chart_scroll.verticalScrollBar().setValue(chart_scroll)
|
||||||
|
self._updating_rows = False
|
||||||
|
|
||||||
|
def _focus_chart_row(self) -> None:
|
||||||
|
if self._updating_rows:
|
||||||
|
return
|
||||||
|
item = self.herb_table.item(self.herb_table.currentRow(), 0)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
target = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
y = 8
|
||||||
|
for _scale, members, _maximum in self.chart.groups:
|
||||||
|
y += self.chart.GROUP_HEIGHT
|
||||||
|
for row in members:
|
||||||
|
if row is target:
|
||||||
|
self.chart_scroll.verticalScrollBar().setValue(y)
|
||||||
|
return
|
||||||
|
y += self.chart.ROW_HEIGHT
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"""The console chrome: the agreement comparison bar and the numbered step rail.
|
||||||
|
|
||||||
|
Both widgets read only what the saved batch carries. A model without a comparable candidate keeps
|
||||||
|
an empty track instead of a zero-length bar, and the rail's counters stay blank until the batch
|
||||||
|
actually reports them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontMetricsF,
|
||||||
|
QLinearGradient,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
|
QPaintEvent,
|
||||||
|
QPalette,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE, MODEL_HUE, MODEL_TEXT, num_font
|
||||||
|
|
||||||
|
MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float | None:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _score(model: Mapping[str, Any]) -> float | None:
|
||||||
|
comparison = _mapping(_mapping(model).get("comparison"))
|
||||||
|
status = comparison.get("status") or _mapping(model).get("comparison_status")
|
||||||
|
if status != "comparable":
|
||||||
|
return None
|
||||||
|
value = _number(comparison.get("score", _mapping(model).get("score")))
|
||||||
|
return None if value is None or value < 0 or value > 100 else value
|
||||||
|
|
||||||
|
|
||||||
|
class ScaleTrack(QWidget):
|
||||||
|
"""A 0–100 track: the model's own fill, and a grey mark where the other model stands."""
|
||||||
|
|
||||||
|
# The design's scale: a 12px band for the rival's label, a 13px rail, then the axis row.
|
||||||
|
LABEL_BAND = 13.0
|
||||||
|
RAIL_TOP = 16.0
|
||||||
|
RAIL_HEIGHT = 13.0
|
||||||
|
AXIS_TOP = 31.0
|
||||||
|
|
||||||
|
def __init__(self, model_key: str, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.model_key = model_key
|
||||||
|
self._value: float | None = None
|
||||||
|
self._other: float | None = None
|
||||||
|
self._other_label = ""
|
||||||
|
self.setFixedHeight(44)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
|
||||||
|
def set_values(self, value: float | None, other: float | None, other_label: str) -> None:
|
||||||
|
self._value, self._other, self._other_label = value, other, other_label
|
||||||
|
self.setAccessibleDescription("暂无可比结果" if value is None else f"{value:.1f}%")
|
||||||
|
self.setToolTip("" if other is None else f"{other_label} 在 {other:.1f}%")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
top, height = self.RAIL_TOP, self.RAIL_HEIGHT
|
||||||
|
radius = height / 2
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(CONSOLE["raised"]))
|
||||||
|
painter.drawRoundedRect(QRectF(0, top, self.width(), height), radius, radius)
|
||||||
|
if self._value:
|
||||||
|
filled = self.width() * self._value / 100
|
||||||
|
hue = QColor(MODEL_HUE[self.model_key])
|
||||||
|
faded = QColor(hue)
|
||||||
|
faded.setAlphaF(0.45)
|
||||||
|
gradient = QLinearGradient(QPointF(0, top), QPointF(filled, top))
|
||||||
|
gradient.setColorAt(0.0, faded)
|
||||||
|
gradient.setColorAt(1.0, hue)
|
||||||
|
painter.setBrush(QBrush(gradient))
|
||||||
|
painter.drawRoundedRect(QRectF(0, top, filled, height), radius, radius)
|
||||||
|
# Quarter dividers sit on the rail itself, as the design draws them.
|
||||||
|
painter.setPen(QColor(CONSOLE["grid_line"]))
|
||||||
|
for fraction in (0.25, 0.5, 0.75):
|
||||||
|
x = self.width() * fraction
|
||||||
|
painter.drawLine(QPointF(x, top), QPointF(x, top + height))
|
||||||
|
axis = QFont(self.font())
|
||||||
|
axis.setPixelSize(9)
|
||||||
|
painter.setFont(axis)
|
||||||
|
painter.setPen(QColor(CONSOLE["faint"]))
|
||||||
|
for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
|
||||||
|
label = f"{int(fraction * 100)}%"
|
||||||
|
width = 44.0
|
||||||
|
left = self.width() * fraction - width / 2
|
||||||
|
align = Qt.AlignmentFlag.AlignCenter
|
||||||
|
if fraction == 0.0:
|
||||||
|
left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
elif fraction == 1.0:
|
||||||
|
left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
painter.drawText(QRectF(left, self.AXIS_TOP, width, 12), align, label)
|
||||||
|
if self._other is not None:
|
||||||
|
x = self.width() * self._other / 100
|
||||||
|
marker = QColor(CONSOLE["muted"])
|
||||||
|
marker.setAlphaF(0.8)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(marker)
|
||||||
|
painter.drawRoundedRect(QRectF(x - 1, top - 4, 2, height + 8), 1, 1)
|
||||||
|
painter.setPen(QColor(CONSOLE["faint"]))
|
||||||
|
painter.drawText(QRectF(max(0.0, min(x - 60, self.width() - 120)), 0, 120, self.LABEL_BAND),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, f"{self._other_label} 在此")
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreLabel(QLabel):
|
||||||
|
"""The big figure with its unit set small and raised, the way the design prints it.
|
||||||
|
|
||||||
|
``text()`` still returns the whole string, so callers and tests read one plain value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SIZE = 27
|
||||||
|
UNIT_SIZE = 13
|
||||||
|
|
||||||
|
def _parts(self) -> tuple[str, str]:
|
||||||
|
text = self.text()
|
||||||
|
for unit in ("%", "pt"):
|
||||||
|
if text.endswith(unit) and len(text) > len(unit):
|
||||||
|
return text[: -len(unit)], unit
|
||||||
|
return text, ""
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||||
|
figure, unit = self._parts()
|
||||||
|
width = QFontMetricsF(num_font(self.SIZE, weight=QFont.Weight.DemiBold)).horizontalAdvance(figure)
|
||||||
|
if unit:
|
||||||
|
width += 2 + QFontMetricsF(num_font(self.UNIT_SIZE)).horizontalAdvance(unit)
|
||||||
|
return QSize(int(width) + 1, int(self.SIZE * 1.1) + 1)
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||||
|
return self.sizeHint()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
figure, unit = self._parts()
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
figure_font = num_font(self.SIZE, weight=QFont.Weight.DemiBold)
|
||||||
|
painter.setFont(figure_font)
|
||||||
|
painter.setPen(self.palette().color(QPalette.ColorRole.WindowText))
|
||||||
|
metrics = QFontMetricsF(figure_font)
|
||||||
|
baseline = (self.height() + metrics.capHeight()) / 2
|
||||||
|
painter.drawText(QPointF(0, baseline), figure)
|
||||||
|
if unit:
|
||||||
|
unit_font = num_font(self.UNIT_SIZE)
|
||||||
|
painter.setFont(unit_font)
|
||||||
|
painter.setPen(QColor(CONSOLE["muted"]))
|
||||||
|
painter.drawText(QPointF(metrics.horizontalAdvance(figure) + 2,
|
||||||
|
baseline - metrics.capHeight() + QFontMetricsF(unit_font).capHeight()),
|
||||||
|
unit)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class AgreementBar(QFrame):
|
||||||
|
"""Both models' agreement on one scale, with the gap between them stated in points."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("AiAgreement")
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(22, 16, 22, 16)
|
||||||
|
layout.setSpacing(24)
|
||||||
|
self.blocks: dict[str, dict[str, Any]] = {}
|
||||||
|
for index, key in enumerate(("qwen", "openai")):
|
||||||
|
if index:
|
||||||
|
divider = QFrame(self)
|
||||||
|
divider.setObjectName("AiFactDivider")
|
||||||
|
divider.setFixedWidth(1)
|
||||||
|
layout.addWidget(divider)
|
||||||
|
layout.addWidget(self._delta_block())
|
||||||
|
divider = QFrame(self)
|
||||||
|
divider.setObjectName("AiFactDivider")
|
||||||
|
divider.setFixedWidth(1)
|
||||||
|
layout.addWidget(divider)
|
||||||
|
# Both heads line up at the top, so a model that failed does not slide its column down.
|
||||||
|
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
"""The design runs a blue-to-violet bar down the card's left edge, inside its rounding."""
|
||||||
|
|
||||||
|
super().paintEvent(event)
|
||||||
|
card = QPainterPath()
|
||||||
|
card.addRoundedRect(QRectF(1, 1, self.width() - 2, self.height() - 2), 9, 9)
|
||||||
|
strip = QPainterPath()
|
||||||
|
strip.addRect(QRectF(0, 0, 4, self.height()))
|
||||||
|
gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.height()))
|
||||||
|
gradient.setColorAt(0.0, QColor(CONSOLE["accent"]))
|
||||||
|
gradient.setColorAt(1.0, QColor(CONSOLE["openai"]))
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.fillPath(card.intersected(strip), QBrush(gradient))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def _model_block(self, key: str) -> QWidget:
|
||||||
|
holder = QWidget(self)
|
||||||
|
column = QVBoxLayout(holder)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(9)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
head.setSpacing(9)
|
||||||
|
dot = QLabel(holder)
|
||||||
|
dot.setFixedSize(9, 9)
|
||||||
|
dot.setStyleSheet(f"background: {MODEL_HUE[key]}; border-radius: 3px;")
|
||||||
|
head.addWidget(dot)
|
||||||
|
name = QLabel(MODEL_NAMES[key], holder)
|
||||||
|
name.setStyleSheet(f"color: {CONSOLE['heading']}; font-size: 13.8px; font-weight: 600;")
|
||||||
|
head.addWidget(name)
|
||||||
|
status = QLabel("尚无报告", holder)
|
||||||
|
status.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
head.addWidget(status)
|
||||||
|
coverage = QLabel("", holder)
|
||||||
|
coverage.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
coverage.setStyleSheet(
|
||||||
|
f"background: {CONSOLE['amber_dim']}; color: {CONSOLE['amber_text']};"
|
||||||
|
f" border: 1px solid {CONSOLE['amber']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
|
||||||
|
head.addWidget(coverage)
|
||||||
|
elapsed = QLabel("", holder)
|
||||||
|
elapsed.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
elapsed.setStyleSheet(
|
||||||
|
f"background: transparent; color: {CONSOLE['faint']};"
|
||||||
|
f" border: 1px solid {CONSOLE['line']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
|
||||||
|
head.addWidget(elapsed)
|
||||||
|
head.addStretch(1)
|
||||||
|
column.addLayout(head)
|
||||||
|
score = ScoreLabel("—", holder)
|
||||||
|
score.setObjectName(f"AiAgreementScore{key.capitalize()}")
|
||||||
|
score.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
column.addWidget(score)
|
||||||
|
caption = QLabel("药味与剂量一致率", holder)
|
||||||
|
caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.5px;")
|
||||||
|
column.addWidget(caption)
|
||||||
|
track = ScaleTrack(key, holder)
|
||||||
|
column.addWidget(track)
|
||||||
|
|
||||||
|
# A failed model states why it stopped and offers its retry on its own block, which is
|
||||||
|
# the only place in the console that belongs to that model alone.
|
||||||
|
failure = QWidget(holder)
|
||||||
|
failure_row = QHBoxLayout(failure)
|
||||||
|
failure_row.setContentsMargins(0, 2, 0, 0)
|
||||||
|
failure_row.setSpacing(8)
|
||||||
|
failure_text = QLabel("", failure)
|
||||||
|
failure_text.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
failure_text.setWordWrap(True)
|
||||||
|
failure_text.setStyleSheet(f"color: {CONSOLE['rose_text']}; font-size: 12px;")
|
||||||
|
failure_row.addWidget(failure_text, 1)
|
||||||
|
retry_slot = QHBoxLayout()
|
||||||
|
retry_slot.setContentsMargins(0, 0, 0, 0)
|
||||||
|
retry_slot.setSpacing(6)
|
||||||
|
failure_row.addLayout(retry_slot)
|
||||||
|
failure.setVisible(False)
|
||||||
|
column.addWidget(failure)
|
||||||
|
|
||||||
|
stage = QLabel("等待处理进度", holder)
|
||||||
|
stage.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
stage.setWordWrap(True)
|
||||||
|
stage.setStyleSheet(f"color: {MODEL_TEXT[key]}; font-size: 12px;")
|
||||||
|
stage.hide()
|
||||||
|
# The plain bar stays as a value carrier for callers and accessibility tools; the painted
|
||||||
|
# track above is the visual, so it is never added to the layout.
|
||||||
|
carrier = QProgressBar(holder)
|
||||||
|
carrier.setRange(0, 1000)
|
||||||
|
carrier.setTextVisible(False)
|
||||||
|
carrier.setFixedHeight(4)
|
||||||
|
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||||
|
carrier.setVisible(False)
|
||||||
|
self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track,
|
||||||
|
"status": status, "failure": failure, "failure_text": failure_text,
|
||||||
|
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
|
||||||
|
return holder
|
||||||
|
|
||||||
|
def views(self, key: str) -> dict[str, Any]:
|
||||||
|
"""Widget map kept stable for the dialog and its regression tests."""
|
||||||
|
|
||||||
|
block = self.blocks[key]
|
||||||
|
return {"card": self, "model_label": block["score"], "status_chip": block["status"],
|
||||||
|
"coverage_chip": block["coverage"], "score": block["score"], "elapsed": block["elapsed"],
|
||||||
|
"agreement_bar": block["carrier"], "gauge": block["track"], "stage": block["stage"],
|
||||||
|
"failure": block["failure"], "failure_text": block["failure_text"]}
|
||||||
|
|
||||||
|
def attach_action(self, key: str, button: QWidget) -> None:
|
||||||
|
"""Host a dialog-owned action (retry) inside that model's failure row."""
|
||||||
|
|
||||||
|
self.blocks[key]["retry_slot"].addWidget(button)
|
||||||
|
|
||||||
|
def set_failure(self, key: str, message: str, retryable: bool) -> None:
|
||||||
|
block = self.blocks[key]
|
||||||
|
block["failure_text"].setText(message)
|
||||||
|
block["failure"].setVisible(bool(message))
|
||||||
|
slot = block["retry_slot"]
|
||||||
|
for index in range(slot.count()):
|
||||||
|
widget = slot.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(retryable)
|
||||||
|
|
||||||
|
def _delta_block(self) -> QWidget:
|
||||||
|
holder = QWidget(self)
|
||||||
|
holder.setFixedWidth(180)
|
||||||
|
column = QVBoxLayout(holder)
|
||||||
|
column.setContentsMargins(0, 6, 0, 0)
|
||||||
|
column.setSpacing(2)
|
||||||
|
column.addStretch(1)
|
||||||
|
self.delta = QLabel("—", holder)
|
||||||
|
self.delta.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.delta.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.delta.setFont(num_font(20, weight=QFont.Weight.DemiBold))
|
||||||
|
self.delta.setStyleSheet(f"color: {CONSOLE['accent_text']}; font-size: 19.5px; font-weight: 600;")
|
||||||
|
column.addWidget(self.delta)
|
||||||
|
self.delta_note = QLabel("等待两个模型", holder)
|
||||||
|
self.delta_note.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.delta_note.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
|
||||||
|
column.addWidget(self.delta_note)
|
||||||
|
self.overlap = QLabel("", holder)
|
||||||
|
self.overlap.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.overlap.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
|
||||||
|
column.addWidget(self.overlap)
|
||||||
|
column.addStretch(1)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
def apply(self, batch: Mapping[str, Any] | None, *, coverage: Mapping[str, str] | None = None,
|
||||||
|
elapsed: Mapping[str, str] | None = None) -> None:
|
||||||
|
data = _mapping(batch)
|
||||||
|
models = _mapping(data.get("models"))
|
||||||
|
scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
block = self.blocks[key]
|
||||||
|
value = scores[key]
|
||||||
|
block["score"].setText("—" if value is None else f"{value:.1f}%")
|
||||||
|
other = scores["openai" if key == "qwen" else "qwen"]
|
||||||
|
block["track"].set_values(value, other, MODEL_NAMES["openai" if key == "qwen" else "qwen"])
|
||||||
|
text = _mapping(coverage).get(key, "")
|
||||||
|
block["coverage"].setText(text)
|
||||||
|
block["coverage"].setVisible(bool(text))
|
||||||
|
spent = _mapping(elapsed).get(key, "")
|
||||||
|
block["elapsed"].setText(spent)
|
||||||
|
block["elapsed"].setVisible(bool(spent))
|
||||||
|
if scores["qwen"] is None or scores["openai"] is None:
|
||||||
|
self.delta.setText("—")
|
||||||
|
self.delta_note.setText("两个模型都可比后才给差值")
|
||||||
|
self.overlap.setText("")
|
||||||
|
return
|
||||||
|
gap = scores["qwen"] - scores["openai"]
|
||||||
|
leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"]
|
||||||
|
self.delta.setText(f"{'+' if gap >= 0 else '−'}{abs(gap):.1f}pt")
|
||||||
|
self.delta_note.setText(f"{leader}领先" if gap else "两模型持平")
|
||||||
|
overlaps = []
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score"))
|
||||||
|
overlaps.append("—" if herb is None else f"{herb:.1f}%")
|
||||||
|
self.overlap.setText("药味重合 " + " / ".join(overlaps))
|
||||||
|
|
||||||
|
|
||||||
|
class _StepButton(QPushButton):
|
||||||
|
"""A step row; the design marks the selected one with a rounded bar down its left edge."""
|
||||||
|
|
||||||
|
BAR_INSET = 9
|
||||||
|
BAR_WIDTH = 3
|
||||||
|
|
||||||
|
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
super().paintEvent(event)
|
||||||
|
if not self.isChecked():
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(CONSOLE["accent"]))
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(0, self.BAR_INSET, self.BAR_WIDTH, self.height() - self.BAR_INSET * 2), 1.5, 1.5)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class StepRail(QFrame):
|
||||||
|
"""The six destinations as numbered steps, with what this batch needs looked at below them."""
|
||||||
|
|
||||||
|
selected = Signal(str)
|
||||||
|
save_requested = Signal()
|
||||||
|
|
||||||
|
FOCUS_ROWS = (("differences", "剂量差异", "项", "amber_text"),
|
||||||
|
("critical", "关键缺口", "项", "rose_text"),
|
||||||
|
("restricted", "附件受限", "个", "text"),
|
||||||
|
("consensus", "三方共识", "味", "accent_text"))
|
||||||
|
|
||||||
|
def __init__(self, steps: tuple[tuple[str, str], ...], parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("AiRail")
|
||||||
|
self.setFixedWidth(232)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
nav = QFrame(self)
|
||||||
|
nav.setObjectName("AiPanel")
|
||||||
|
nav_layout = QVBoxLayout(nav)
|
||||||
|
nav_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
nav_layout.setSpacing(0)
|
||||||
|
self._tones: dict[str, str] = {}
|
||||||
|
self.buttons: dict[str, QPushButton] = {}
|
||||||
|
self.badges: dict[str, QLabel] = {}
|
||||||
|
self.numbers: dict[str, QLabel] = {}
|
||||||
|
self.names: dict[str, QLabel] = {}
|
||||||
|
for index, (key, text) in enumerate(steps, start=1):
|
||||||
|
button = _StepButton(nav)
|
||||||
|
button.setObjectName("AiStep")
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setChecked(index == 1)
|
||||||
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
button.setFixedHeight(42)
|
||||||
|
button.clicked.connect(lambda _checked=False, target=key: self.selected.emit(target))
|
||||||
|
row = QHBoxLayout(button)
|
||||||
|
row.setContentsMargins(11, 0, 11, 0)
|
||||||
|
row.setSpacing(10)
|
||||||
|
number = QLabel(f"{index:02d}", button)
|
||||||
|
number.setObjectName("AiStepNumber")
|
||||||
|
number.setFixedSize(22, 22)
|
||||||
|
number.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
number.setFont(num_font(10, weight=QFont.Weight.Bold))
|
||||||
|
row.addWidget(number)
|
||||||
|
name = QLabel(text, button)
|
||||||
|
name.setObjectName("AiStepName")
|
||||||
|
row.addWidget(name, 1)
|
||||||
|
badge = QLabel("", button)
|
||||||
|
badge.setObjectName("AiStepBadge")
|
||||||
|
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
badge.setMinimumWidth(20)
|
||||||
|
badge.setFixedHeight(17)
|
||||||
|
badge.setFont(num_font(10))
|
||||||
|
row.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
self.buttons[key] = button
|
||||||
|
self.badges[key] = badge
|
||||||
|
self.numbers[key] = number
|
||||||
|
self.names[key] = name
|
||||||
|
nav_layout.addWidget(button)
|
||||||
|
layout.addWidget(nav)
|
||||||
|
self.set_current(steps[0][0] if steps else "")
|
||||||
|
|
||||||
|
focus = QFrame(self)
|
||||||
|
focus.setObjectName("AiPanel")
|
||||||
|
focus_layout = QVBoxLayout(focus)
|
||||||
|
focus_layout.setContentsMargins(12, 13, 12, 14)
|
||||||
|
focus_layout.setSpacing(7)
|
||||||
|
caption = QLabel("本次关注", focus)
|
||||||
|
caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px; letter-spacing: 1.4px;"
|
||||||
|
" padding-left: 4px;")
|
||||||
|
focus_layout.addWidget(caption)
|
||||||
|
self.focus_values: dict[str, QLabel] = {}
|
||||||
|
for key, text, unit, tone in self.FOCUS_ROWS:
|
||||||
|
row = QFrame(focus)
|
||||||
|
row.setObjectName("AiFocusRow")
|
||||||
|
row_layout = QHBoxLayout(row)
|
||||||
|
row_layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
row_layout.setSpacing(8)
|
||||||
|
name = QLabel(text, row)
|
||||||
|
name.setStyleSheet(f"color: {CONSOLE['muted']}; font-size: 11.25px;")
|
||||||
|
row_layout.addWidget(name)
|
||||||
|
row_layout.addStretch(1)
|
||||||
|
value = QLabel(f"— {unit}", row)
|
||||||
|
value.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
value.setFont(num_font(14))
|
||||||
|
value.setStyleSheet(f"color: {CONSOLE[tone]};")
|
||||||
|
row_layout.addWidget(value)
|
||||||
|
self.focus_values[key] = value
|
||||||
|
focus_layout.addWidget(row)
|
||||||
|
self.save_button = QPushButton("保存本次复核", focus)
|
||||||
|
self.save_button.setObjectName("AiPrimaryAction")
|
||||||
|
self.save_button.setFixedHeight(34)
|
||||||
|
self.save_button.clicked.connect(self.save_requested.emit)
|
||||||
|
focus_layout.addWidget(self.save_button)
|
||||||
|
layout.addWidget(focus)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
BADGE_TONES = {"hot": ("rose_dim", "rose_text"), "warn": ("amber_dim", "amber_text")}
|
||||||
|
|
||||||
|
def set_current(self, key: str) -> None:
|
||||||
|
"""The selected step is a tinted row with a blue index chip, not a solid blue button."""
|
||||||
|
|
||||||
|
for target, button in self.buttons.items():
|
||||||
|
selected = target == key
|
||||||
|
button.setChecked(selected)
|
||||||
|
self.numbers[target].setStyleSheet(
|
||||||
|
f"background: {CONSOLE['accent'] if selected else CONSOLE['raised']};"
|
||||||
|
f" color: {'#F2F7FF' if selected else CONSOLE['faint']}; border-radius: 6px;")
|
||||||
|
self.names[target].setStyleSheet(
|
||||||
|
f"color: {CONSOLE['heading'] if selected else CONSOLE['muted']}; font-size: 12.6px;"
|
||||||
|
+ (" font-weight: 600;" if selected else ""))
|
||||||
|
self._paint_badge(target)
|
||||||
|
|
||||||
|
def _paint_badge(self, key: str) -> None:
|
||||||
|
background, colour = self.BADGE_TONES.get(self._tones.get(key, ""), ("raised", "faint"))
|
||||||
|
self.badges[key].setStyleSheet(
|
||||||
|
f"background: {CONSOLE[background]}; color: {CONSOLE[colour]};"
|
||||||
|
" border-radius: 8px; padding: 0 6px;")
|
||||||
|
|
||||||
|
def set_badges(self, counts: Mapping[str, Any], tones: Mapping[str, str] | None = None) -> None:
|
||||||
|
"""Counts, and the severity that decides whether one reads as rose, amber or quiet."""
|
||||||
|
|
||||||
|
self._tones = dict(tones or {})
|
||||||
|
for key, badge in self.badges.items():
|
||||||
|
value = counts.get(key)
|
||||||
|
badge.setText("" if value in (None, "") else str(value))
|
||||||
|
badge.setVisible(bool(badge.text()))
|
||||||
|
self._paint_badge(key)
|
||||||
|
|
||||||
|
def set_focus(self, counts: Mapping[str, Any]) -> None:
|
||||||
|
for key, _text, unit, _tone in self.FOCUS_ROWS:
|
||||||
|
value = counts.get(key)
|
||||||
|
self.focus_values[key].setText(f"{'—' if value is None else value} {unit}")
|
||||||
|
|
||||||
|
|
||||||
|
def rail_qss() -> str:
|
||||||
|
"""The rail and agreement styles for the palette that is active right now."""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
QFrame#AiRail {{ background: transparent; border: 0; }}
|
||||||
|
QFrame#AiFocusRow {{ background: {CONSOLE['surface_2']}; border: 0; border-radius: 7px; }}
|
||||||
|
QPushButton#AiStep {{ background: transparent; border: 1px solid transparent; border-radius: 7px;
|
||||||
|
text-align: left; }}
|
||||||
|
QPushButton#AiStep:hover {{ background: {CONSOLE['surface_2']}; }}
|
||||||
|
QPushButton#AiStep:checked {{ background: {CONSOLE['selection']};
|
||||||
|
border-color: {CONSOLE['selection_line']}; }}
|
||||||
|
QLabel#AiStepNumber {{ color: {CONSOLE['faint']}; }}
|
||||||
|
QLabel#AiStepName {{ color: {CONSOLE['muted']}; font-size: 12.6px; }}
|
||||||
|
QLabel#AiStepBadge {{ color: {CONSOLE['faint']}; background: {CONSOLE['raised']};
|
||||||
|
border-radius: 8px; padding: 0 6px; }}
|
||||||
|
QFrame#AiAgreement {{ background: {CONSOLE['surface']}; border: 1px solid {CONSOLE['line_soft']};
|
||||||
|
border-radius: 10px; }}
|
||||||
|
QLabel#AiAgreementScoreQwen {{ color: {MODEL_TEXT['qwen']}; font-size: 27px; font-weight: 600; }}
|
||||||
|
QLabel#AiAgreementScoreOpenai {{ color: {MODEL_TEXT['openai']}; font-size: 27px; font-weight: 600; }}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Painted glyphs for the prescription analysis window.
|
||||||
|
|
||||||
|
The window ships no image assets, so every icon in the design is drawn here with QPainter at the
|
||||||
|
size it is used. Each glyph is line art on a transparent background; the colour is supplied by the
|
||||||
|
caller, which keeps a glyph usable on a card, inside a tinted tile, or on the navigation bar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QIcon,
|
||||||
|
QLinearGradient,
|
||||||
|
QPainter,
|
||||||
|
QPaintEvent,
|
||||||
|
QPen,
|
||||||
|
QPixmap,
|
||||||
|
QPolygonF,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
|
||||||
|
|
||||||
|
|
||||||
|
def _pen(painter: QPainter, colour: str, width: float) -> QPen:
|
||||||
|
pen = QPen(QColor(colour))
|
||||||
|
pen.setWidthF(width)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
return pen
|
||||||
|
|
||||||
|
|
||||||
|
def paint_glyph(painter: QPainter, kind: str, box: QRectF, colour: str) -> None:
|
||||||
|
"""Draw one glyph inside ``box``. Unknown names draw nothing rather than a placeholder."""
|
||||||
|
|
||||||
|
painter.save()
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
x, y, w, h = box.x(), box.y(), box.width(), box.height()
|
||||||
|
stroke = max(1.2, min(w, h) / 11)
|
||||||
|
if kind == "bars":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
for index, height in enumerate((0.45, 0.75, 0.6)):
|
||||||
|
left = x + w * (0.22 + index * 0.28)
|
||||||
|
painter.drawLine(QPointF(left, y + h * 0.82), QPointF(left, y + h * (0.82 - height)))
|
||||||
|
elif kind == "doc":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.12, w * 0.56, h * 0.76), w * 0.08, w * 0.08)
|
||||||
|
for index in range(2):
|
||||||
|
top = y + h * (0.38 + index * 0.2)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.34, top), QPointF(x + w * 0.66, top))
|
||||||
|
elif kind == "box":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
top, bottom, middle = y + h * 0.2, y + h * 0.8, y + h * 0.5
|
||||||
|
left, right = x + w * 0.16, x + w * 0.84
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, top), QPointF(right, middle * 0.75 + top * 0.25),
|
||||||
|
QPointF(right, bottom - h * 0.12), QPointF(x + w * 0.5, bottom),
|
||||||
|
QPointF(left, bottom - h * 0.12), QPointF(left, middle * 0.75 + top * 0.25)]))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.5), QPointF(x + w * 0.5, bottom))
|
||||||
|
elif kind == "image":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.16, y + h * 0.22, w * 0.68, h * 0.56), w * 0.08, w * 0.08)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.7), QPointF(x + w * 0.42, y + h * 0.48),
|
||||||
|
QPointF(x + w * 0.58, y + h * 0.66), QPointF(x + w * 0.68, y + h * 0.56)]))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.64, y + h * 0.36), stroke * 0.9, stroke * 0.9)
|
||||||
|
elif kind == "clock":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
painter.drawLine(centre, QPointF(x + w * 0.5, y + h * 0.3))
|
||||||
|
painter.drawLine(centre, QPointF(x + w * 0.66, y + h * 0.58))
|
||||||
|
elif kind == "trend":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.18, y + h * 0.68), QPointF(x + w * 0.4, y + h * 0.46),
|
||||||
|
QPointF(x + w * 0.56, y + h * 0.58), QPointF(x + w * 0.82, y + h * 0.28)]))
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.62, y + h * 0.28), QPointF(x + w * 0.82, y + h * 0.28),
|
||||||
|
QPointF(x + w * 0.82, y + h * 0.48)]))
|
||||||
|
elif kind == "bell":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([
|
||||||
|
QPointF(x + w * 0.24, y + h * 0.68), QPointF(x + w * 0.3, y + h * 0.58),
|
||||||
|
QPointF(x + w * 0.3, y + h * 0.42), QPointF(x + w * 0.5, y + h * 0.2),
|
||||||
|
QPointF(x + w * 0.7, y + h * 0.42), QPointF(x + w * 0.7, y + h * 0.58),
|
||||||
|
QPointF(x + w * 0.76, y + h * 0.68), QPointF(x + w * 0.24, y + h * 0.68)]))
|
||||||
|
painter.drawArc(QRectF(x + w * 0.4, y + h * 0.66, w * 0.2, h * 0.18), 0, -180 * 16)
|
||||||
|
elif kind == "clipboard":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.2, w * 0.56, h * 0.66), w * 0.08, w * 0.08)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.48), QPointF(x + w * 0.64, y + h * 0.48))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.64), QPointF(x + w * 0.56, y + h * 0.64))
|
||||||
|
elif kind == "pencil":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.76), QPointF(x + w * 0.28, y + h * 0.6),
|
||||||
|
QPointF(x + w * 0.64, y + h * 0.24), QPointF(x + w * 0.78, y + h * 0.38),
|
||||||
|
QPointF(x + w * 0.42, y + h * 0.74), QPointF(x + w * 0.24, y + h * 0.76)]))
|
||||||
|
elif kind == "bulb":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawArc(QRectF(x + w * 0.28, y + h * 0.18, w * 0.44, h * 0.46), 0, 180 * 16)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.28, y + h * 0.41), QPointF(x + w * 0.38, y + h * 0.62))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.72, y + h * 0.41), QPointF(x + w * 0.62, y + h * 0.62))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.38, y + h * 0.66), QPointF(x + w * 0.62, y + h * 0.66))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.42, y + h * 0.78), QPointF(x + w * 0.58, y + h * 0.78))
|
||||||
|
elif kind == "flask":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.38, y + h * 0.2), QPointF(x + w * 0.62, y + h * 0.2))
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.44, y + h * 0.2), QPointF(x + w * 0.44, y + h * 0.44),
|
||||||
|
QPointF(x + w * 0.24, y + h * 0.78), QPointF(x + w * 0.76, y + h * 0.78),
|
||||||
|
QPointF(x + w * 0.56, y + h * 0.44), QPointF(x + w * 0.56, y + h * 0.2)]))
|
||||||
|
elif kind == "alert":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.33), QPointF(x + w * 0.5, y + h * 0.56))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.68), stroke * 0.7, stroke * 0.7)
|
||||||
|
elif kind == "paperclip":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.18, y + h * 0.22, w * 0.64, h * 0.5), w * 0.1, w * 0.1)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.3, y + h * 0.72), QPointF(x + w * 0.3, y + h * 0.84))
|
||||||
|
elif kind == "info":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.46), QPointF(x + w * 0.5, y + h * 0.68))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.34), stroke * 0.7, stroke * 0.7)
|
||||||
|
elif kind == "refresh":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawArc(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), 40 * 16, 280 * 16)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.72, y + h * 0.12), QPointF(x + w * 0.86, y + h * 0.34),
|
||||||
|
QPointF(x + w * 0.6, y + h * 0.32)]))
|
||||||
|
elif kind == "save":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), w * 0.08, w * 0.08)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.2), QPointF(x + w * 0.36, y + h * 0.42))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.42))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.64, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.2))
|
||||||
|
painter.drawRect(QRectF(x + w * 0.36, y + h * 0.56, w * 0.28, h * 0.24))
|
||||||
|
elif kind == "chevron":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.4, y + h * 0.28), QPointF(x + w * 0.62, y + h * 0.5),
|
||||||
|
QPointF(x + w * 0.4, y + h * 0.72)]))
|
||||||
|
elif kind == "shield":
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, y + h * 0.16), QPointF(x + w * 0.82, y + h * 0.3),
|
||||||
|
QPointF(x + w * 0.82, y + h * 0.56), QPointF(x + w * 0.5, y + h * 0.84),
|
||||||
|
QPointF(x + w * 0.18, y + h * 0.56), QPointF(x + w * 0.18, y + h * 0.3)]))
|
||||||
|
elif kind == "check":
|
||||||
|
_pen(painter, colour, stroke * 1.2)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.32, y + h * 0.52), QPointF(x + w * 0.45, y + h * 0.65),
|
||||||
|
QPointF(x + w * 0.7, y + h * 0.37)]))
|
||||||
|
elif kind == "spark":
|
||||||
|
# The 千问 mark: three crossing strokes forming a six-pointed star.
|
||||||
|
_pen(painter, colour, stroke * 1.1)
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
radius = min(w, h) * 0.3
|
||||||
|
for angle in (90, 30, -30):
|
||||||
|
from math import cos, radians, sin
|
||||||
|
dx, dy = cos(radians(angle)) * radius, -sin(radians(angle)) * radius
|
||||||
|
painter.drawLine(QPointF(centre.x() - dx, centre.y() - dy), QPointF(centre.x() + dx, centre.y() + dy))
|
||||||
|
elif kind == "knot":
|
||||||
|
# The OpenAI mark, reduced to the interlocking hexagon it is built from.
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
from math import cos, radians, sin
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
radius = min(w, h) * 0.3
|
||||||
|
points = [QPointF(centre.x() + cos(radians(angle)) * radius, centre.y() + sin(radians(angle)) * radius)
|
||||||
|
for angle in range(0, 360, 60)]
|
||||||
|
painter.drawPolygon(QPolygonF(points))
|
||||||
|
painter.drawLine(points[0], points[3])
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
|
class Glyph(QWidget):
|
||||||
|
"""A single painted icon at a fixed size."""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, colour: str, size: int = 18, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.kind, self.colour = kind, colour
|
||||||
|
self.setFixedSize(size, size)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
def set_colour(self, colour: str) -> None:
|
||||||
|
if colour != self.colour:
|
||||||
|
self.colour = colour
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
paint_glyph(painter, self.kind, QRectF(0, 0, self.width(), self.height()), self.colour)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class LogoTile(QWidget):
|
||||||
|
"""A rounded tile with a glyph on it: the window mark and the two model marks."""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, *, start: str, end: str, glyph: str = "#FFFFFF",
|
||||||
|
size: int = 34, radius: float = 10.0, circle: bool = False,
|
||||||
|
parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.kind, self.start, self.end, self.glyph_colour = kind, start, end, glyph
|
||||||
|
self.radius, self.circle = radius, circle
|
||||||
|
self.setFixedSize(size, size)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
box = QRectF(0, 0, self.width(), self.height())
|
||||||
|
gradient = QLinearGradient(box.topLeft(), box.bottomRight())
|
||||||
|
gradient.setColorAt(0.0, QColor(self.start))
|
||||||
|
gradient.setColorAt(1.0, QColor(self.end))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QBrush(gradient))
|
||||||
|
if self.circle:
|
||||||
|
painter.drawEllipse(box)
|
||||||
|
else:
|
||||||
|
painter.drawRoundedRect(box, self.radius, self.radius)
|
||||||
|
paint_glyph(painter, self.kind, box, self.glyph_colour)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(self.width(), self.height())
|
||||||
|
|
||||||
|
|
||||||
|
def window_mark(parent: QWidget | None = None, size: int = 38) -> LogoTile:
|
||||||
|
tile = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"],
|
||||||
|
size=size, radius=11, parent=parent)
|
||||||
|
tile.setAccessibleName("诊断与药方对照")
|
||||||
|
return tile
|
||||||
|
|
||||||
|
|
||||||
|
def model_mark(model_key: str, parent: QWidget | None = None, size: int = 30) -> LogoTile:
|
||||||
|
if model_key == "openai":
|
||||||
|
return LogoTile("knot", start=TECH_BLUE["openai_dim"], end=TECH_BLUE["surface_2"],
|
||||||
|
glyph=TECH_BLUE["openai_text"], size=size, circle=True, parent=parent)
|
||||||
|
return LogoTile("spark", start=TECH_BLUE["qwen_dim"], end=TECH_BLUE["surface_2"],
|
||||||
|
glyph=TECH_BLUE["qwen_text"], size=size, radius=9, parent=parent)
|
||||||
|
|
||||||
|
|
||||||
|
def glyph_icon(kind: str, colour: str, size: int = 16) -> QIcon:
|
||||||
|
"""The same line art as a QIcon, for buttons that place their own icon and label."""
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
paint_glyph(painter, kind, QRectF(0, 0, size, size), colour)
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Presentation-only Chinese labels for saved prescription analysis payloads.
|
||||||
|
|
||||||
|
Keep source identifiers, enum keys and statistics untouched in the repository data.
|
||||||
|
Only explicitly structured metadata is localized; clinical prose is not translated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SOURCE_LABELS = {
|
||||||
|
"diagnoses": "诊单", "doctor_notes": "医生笔记", "tracking_notes": "随访记录",
|
||||||
|
"prescriptions": "历史处方", "call_records": "问诊通话", "video_calls": "视频问诊",
|
||||||
|
"transcript_segments": "转写片段", "chat_records": "聊天记录", "daily_records": "日常记录",
|
||||||
|
"blood_records": "血糖血压记录", "blood_glucose_pressure": "血糖血压记录",
|
||||||
|
"diet_records": "饮食记录", "diet": "饮食记录", "exercise_records": "运动记录", "exercise": "运动记录",
|
||||||
|
"im_messages": "即时聊天记录", "tencent_im": "即时聊天记录",
|
||||||
|
"wechat_messages": "企业微信聊天记录", "wechat_work": "企业微信聊天记录",
|
||||||
|
"target_plan": "本次处方方案", "clinical": "临床资料", "file": "附件",
|
||||||
|
"tongue": "舌象", "tongue_image": "舌象图片", "tongue_images": "舌象图片",
|
||||||
|
"clinical_attachment": "临床附件", "patient": "患者资料",
|
||||||
|
}
|
||||||
|
|
||||||
|
SYSTEM_LABELS = {
|
||||||
|
"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": "来源历史版本无法核验",
|
||||||
|
"ARCHIVE_SYNC_WATERMARK_UNAVAILABLE": "归档同步完整性尚未核验",
|
||||||
|
"TRANSCRIPT_NOT_VERIFIED_COMPLETE": "问诊转写完整性尚未核验",
|
||||||
|
"TRANSCRIPT_NOT_FINAL": "问诊转写尚未完整归档",
|
||||||
|
"TRANSCRIPT_PARTIAL": "问诊转写仅部分完成", "TRANSCRIPT_FAILED": "问诊转写失败",
|
||||||
|
"TRANSCRIPT_RUNNING": "问诊转写进行中", "TRANSCRIPT_PENDING": "等待问诊转写",
|
||||||
|
"SOURCE_AUTHORIZATION_LINK_UNAVAILABLE": "来源缺少可核验的授权关联",
|
||||||
|
"SOURCE_PATIENT_CONFLICT": "来源的患者关联存在冲突",
|
||||||
|
"SOURCE_ACCESS_RESTRICTED": "来源访问受限",
|
||||||
|
"UNLINKED_SOURCE_REQUIRES_AUTHORIZATION": "未关联来源需核验访问权限",
|
||||||
|
"SOURCE_READ_UNAVAILABLE": "来源暂时无法读取",
|
||||||
|
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": "附件可能包含本次处方,独立性未核验",
|
||||||
|
"UNSTRUCTURED_TARGET_PLAN_LEAKAGE_UNVERIFIED": "非结构化资料可能包含本次处方,独立性未核验",
|
||||||
|
"TARGET_PLAN_COPY_ISOLATED": "已隔离资料中复制的本次处方内容",
|
||||||
|
"CRITICAL_CLINICAL_FACT_MISSING": "关键临床资料缺失",
|
||||||
|
"FILE_STORAGE_AUTHORIZATION_UNVERIFIED": "附件存储访问权限尚未核验",
|
||||||
|
"FILE_CONTENT_VERSION_UNVERIFIED": "附件内容版本尚未核验",
|
||||||
|
"PATIENT_BINDING_REQUIRED": "需完善患者与诊单关联",
|
||||||
|
"PATIENT_BINDING_OR_PERMISSION_REQUIRED": "需核验患者关联与资料访问权限",
|
||||||
|
"ACCESS_REVOKED": "资料访问权限已变更", "SOURCE_CHANGED": "资料或处方已更新,请查看新版本",
|
||||||
|
"BUDGET_PAUSED": "已达到分析预算,等待额度恢复",
|
||||||
|
"CONFIG_INVALID": "模型配置无效,请联系管理员", "CONFIG_DISABLED": "模型分析尚未启用",
|
||||||
|
"UPSTREAM_AUTH_FAILED": "模型服务认证失败,请联系管理员",
|
||||||
|
"UPSTREAM_TIMEOUT": "模型响应超时,可稍后重试", "UPSTREAM_BUSY": "模型服务繁忙,可稍后重试",
|
||||||
|
"UPSTREAM_UNAVAILABLE": "模型服务暂不可用", "UPSTREAM_REJECTED": "模型服务未接受本次请求",
|
||||||
|
"UPSTREAM_FAILED": "模型服务处理失败", "EMPTY_RESPONSE": "模型未返回内容",
|
||||||
|
"INCOMPLETE_RESPONSE": "模型返回内容不完整", "INVALID_RESPONSE": "模型返回内容未通过校验",
|
||||||
|
"RESPONSE_INVALID": "模型返回内容未通过校验", "INVALID_REPORT_OUTPUT": "模型报告未通过格式校验",
|
||||||
|
"INVALID_EVIDENCE_OUTPUT": "模型证据来源未通过校验",
|
||||||
|
"INVALID_FILE_EVIDENCE_OUTPUT": "模型附件证据未通过校验",
|
||||||
|
"CONTEXT_TOO_LARGE": "资料超过本次处理预算",
|
||||||
|
"INPUT_TOKEN_BUDGET_EXCEEDED": "输入资料超过本次处理预算",
|
||||||
|
"SYNTHESIS_BUDGET_EXCEEDED": "资料汇总超过本次处理预算",
|
||||||
|
"FINAL_CONTEXT_EXCEEDS_BUDGET": "来源与缺口说明超过汇总预算,请联系管理员",
|
||||||
|
"TOTAL_CALL_BUDGET_EXCEEDED": "模型调用次数达到本次上限",
|
||||||
|
"SOURCE_UNIT_EXCEEDS_BUDGET": "单条来源资料超过处理预算",
|
||||||
|
"RESPONSE_SIZE_EXCEEDED": "模型返回内容超过长度上限",
|
||||||
|
"LEASE_EXPIRED": "工作进程中断,任务等待恢复",
|
||||||
|
"SOURCE_PREPARATION_FAILED": "来源资料准备失败", "INTERNAL_ERROR": "分析处理异常,可稍后重试",
|
||||||
|
"GENERATION_FAILED": "报告生成失败,可稍后重试", "CHECKPOINT_REJECTED": "分析进度保存未通过校验",
|
||||||
|
"INVALID_PROFILE": "模型配置未通过校验", "INVALID_FROZEN_CONTEXT": "冻结资料未通过校验",
|
||||||
|
"INVALID_FILE_MANIFEST": "附件清单未通过校验", "SOURCE_GAP": "来源资料存在缺口",
|
||||||
|
"FILE_CAPABILITY_DISABLED": "模型附件处理能力尚未启用",
|
||||||
|
"FILE_UNAVAILABLE_OR_UNSUPPORTED": "附件不可用或格式不受支持",
|
||||||
|
"FILE_TYPE_UNSUPPORTED": "附件格式不受支持",
|
||||||
|
"STRICT_FILES_INVALID_OR_LIMIT": "附件校验未通过或超过处理上限",
|
||||||
|
"FILE_DELIVERY_UNVERIFIED": "附件送达情况尚未核验",
|
||||||
|
"MODEL_REPORTED_UNREADABLE": "模型无法读取附件", "MODEL_REPORTED_UNSUPPORTED": "模型不支持此附件",
|
||||||
|
"MODEL_FILE_OUTPUT_INVALID": "模型未能正确解析这组附件",
|
||||||
|
"AI_ANALYSIS_CIPHER_INVALID": "报告加密数据无法读取",
|
||||||
|
"AI_ANALYSIS_ENCRYPTION_FAILED": "报告加密保存失败",
|
||||||
|
"AI_ANALYSIS_KEY_INVALID": "报告加密配置无效", "AI_ANALYSIS_KEY_UNAVAILABLE": "报告加密配置不可用",
|
||||||
|
"formulation_mismatch": "剂型不同,未提供经确认的换算规则",
|
||||||
|
"dose_basis_mismatch": "剂量基准不同", "unit_mismatch": "剂量单位不同",
|
||||||
|
"unknown_formulation": "剂型缺失或不受支持", "empty_prescription": "处方为空或药味结构无效",
|
||||||
|
"catalog_unavailable": "缺少可用的药材字典", "invalid_herb": "药味结构无效",
|
||||||
|
"ambiguous_herb_name": "药名存在多种字典匹配", "unknown_herb_name": "药名未匹配药材字典",
|
||||||
|
"doctor_identity_mismatch": "医生药材编号与规范药名不一致",
|
||||||
|
"processing_conflict": "炮制信息与药材字典冲突或无效",
|
||||||
|
"ambiguous_herb_role": "主辅方、给药途径或分组不明确",
|
||||||
|
"missing_or_unknown_unit": "剂量单位缺失或不受支持",
|
||||||
|
"missing_or_unknown_dose_basis": "每剂或每日剂量基准不明确",
|
||||||
|
"invalid_dosage": "剂量数值无效", "invalid_herb_usage": "药味煎服说明格式无效",
|
||||||
|
"duplicate_semantics_conflict": "重复药项的单位、剂量基准或煎服说明不一致",
|
||||||
|
"no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物",
|
||||||
|
"baseline_ineligible": "不符合独立基线统计条件", "incomplete_coverage": "资料覆盖不完整",
|
||||||
|
"missing_result": "缺少模型结果", "invalid_score": "一致度分值无效",
|
||||||
|
"missing_algorithm_version": "缺少算法版本", "transcript_not_final": "问诊转写尚未完整归档",
|
||||||
|
"event_patient_conflict": "开方事件的患者关联冲突", "duplicate_baseline_conflict": "重复基线结果存在冲突",
|
||||||
|
"review_conflict": "复核记录存在冲突", "review_not_completed": "复核尚未完成",
|
||||||
|
"review_not_independent": "复核不具独立性", "review_disputed": "复核存在争议",
|
||||||
|
"review_not_evaluable": "复核不可评价", "invalid_review_outcome": "复核结论无效",
|
||||||
|
"unknown_review_sampling": "复核抽样方式未确认", "start_at_required": "需设置分析起始时间",
|
||||||
|
"processed": "已处理", "restricted": "访问受限", "error": "处理失败", "timeout": "处理超时",
|
||||||
|
"delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
|
||||||
|
"ok": "可比条件已通过", "doctor": "医生方", "candidate": "候选方", "both": "双方",
|
||||||
|
"low": "低风险", "medium": "中风险", "high": "高风险", "none": "无",
|
||||||
|
"main": "主方", "oral": "口服", "external": "外用",
|
||||||
|
"raw": "生品", "image": "图片", "document": "文档", "remote_url": "远程附件",
|
||||||
|
"stratified_versions": "按版本分层统计", "single_version": "单一版本", "no_valid_samples": "无有效样本",
|
||||||
|
"no_samples": "未建立复核样本", "recorded": "已记录", "conflict": "存在冲突",
|
||||||
|
"not_evaluable": "不可评价", "qualified": "合格", "needs_revision": "需修订", "unqualified": "不合格",
|
||||||
|
"random": "随机抽样", "stratified": "分层抽样", "risk_directed": "按风险抽样",
|
||||||
|
"stratified_sampling": "按抽样方式分层统计",
|
||||||
|
"spelling_aliases_only_no_quantity_conversion": "仅规范单位写法,不进行剂量换算",
|
||||||
|
"g": "克", "mg": "毫克", "kg": "千克", "ml": "毫升", "mL": "毫升", "l": "升", "L": "升",
|
||||||
|
"qwen": "千问", "openai": "OpenAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTRA_FIELD_LABELS = {
|
||||||
|
"source": "来源", "code": "原因说明", "reason_code": "原因说明", "error_code": "错误原因",
|
||||||
|
"error_message": "错误说明", "message": "说明", "detail": "详情", "details": "详细记录",
|
||||||
|
"reason_message": "原因说明", "field": "字段", "value": "记录值", "side": "所属处方",
|
||||||
|
"row": "药项序号", "rows": "逐味记录", "key": "药项标识", "id": "编号", "medicine_id": "药材编号",
|
||||||
|
"prescription_id": "处方编号", "diagnosis_id": "诊单编号", "patient_id": "患者编号",
|
||||||
|
"doctor_id": "医生编号", "doctor_name": "医生姓名", "model_key": "模型", "model_name": "模型名称",
|
||||||
|
"configured_model_name": "配置的模型名称", "comparison_status": "可比状态", "coverage_status": "资料覆盖状态",
|
||||||
|
"comparison_type": "比较类型", "validity": "报告有效性", "version_verified": "内容版本已核验",
|
||||||
|
"source_record_count": "来源记录总数", "missing_count": "资料缺口数", "snapshot_complete": "资料快照完整",
|
||||||
|
"may_be_truncated": "资料可能截断", "history_versioning": "历史版本核验", "archive_sync_verified": "归档同步已核验",
|
||||||
|
"file_ids": "附件编号", "evidence_file_ids": "证据附件编号", "covered_source_ids": "已覆盖来源编号",
|
||||||
|
"source_kind": "来源类型", "kind": "来源类型", "type": "类型", "purpose": "用途",
|
||||||
|
"transfer_method": "附件传递方式", "content_hash": "内容指纹", "dictionary_hash": "药材字典指纹",
|
||||||
|
"source_hash": "来源指纹", "url": "附件地址", "uri": "附件地址", "path": "附件路径",
|
||||||
|
"age": "年龄", "gender": "性别", "gender_label": "性别说明", "allergy_history": "过敏史",
|
||||||
|
"pregnancy_history": "妊娠与哺乳情况", "current_medications": "当前用药",
|
||||||
|
"allergy_history_text": "过敏史正文", "allergy_history_desc": "过敏史说明",
|
||||||
|
"pregnancy_history_text": "妊娠与哺乳正文", "pregnancy_history_desc": "妊娠与哺乳说明",
|
||||||
|
"current_medicine": "当前用药", "current_medication": "当前用药",
|
||||||
|
"prescription": "处方", "prescription_opinion": "处方意见", "prescription_advice": "处方建议",
|
||||||
|
"treatment_principle": "治则", "doctor_advice": "医嘱", "prescription_date": "开方日期",
|
||||||
|
"issues": "需核对问题", "formulation": "剂型", "items": "药项", "bases": "剂量基准",
|
||||||
|
"merges": "重复药项合并", "defaults": "默认值记录", "identity_complete": "药项身份核验完整",
|
||||||
|
"raw_herb_count": "原始药项数", "source_rows": "原始药项序号", "source_names": "原始药名",
|
||||||
|
"original_dosages": "原始剂量", "usage": "煎服说明", "doctor": "医生方", "candidate": "候选方",
|
||||||
|
"dictionary_versions": "药材字典版本", "unit_policy": "单位处理规则", "denominator": "一致度计算分母",
|
||||||
|
"matched_contribution_sum": "共同药项贡献合计", "special_usage": "特殊用法",
|
||||||
|
"decoction_instruction": "煎药说明", "usage_note": "服法备注", "before": "处理前", "after": "处理后",
|
||||||
|
"dosage_amount": "每次用量", "dosage_unit": "每次用量单位", "dosage_bag_count": "每次袋数", "aux_usage": "辅助用法",
|
||||||
|
"score": "药味剂量一致度", "herb_score": "纯药味重合度", "model": "模型",
|
||||||
|
"schema_version": "资料格式版本", "decision_at": "处方决策时间", "created_at": "建立时间", "updated_at": "更新时间",
|
||||||
|
"source_diagnosis_ids": "来源诊单编号", "redaction_manifest": "处方内容隔离清单",
|
||||||
|
"total_count": "开方事件数", "total_events": "开方事件数", "patient_count": "患者数",
|
||||||
|
"eligible_count": "有效比较数", "valid_count": "有效比较数", "excluded_count": "排除样本数",
|
||||||
|
"coverage_rate": "覆盖率", "coverage_percent": "覆盖率", "paired_count": "双模型共同有效样本数",
|
||||||
|
"excluded_reasons": "排除原因及数量", "exclusion_reasons": "排除原因及数量", "exclusion_reason": "排除原因",
|
||||||
|
"aggregation_status": "统计汇总方式", "paired_strata": "双模型版本分层", "models": "模型统计",
|
||||||
|
"review": "专家复核", "reviews": "专家复核", "evaluated_count": "可评价样本数", "evaluable_count": "可评价样本数",
|
||||||
|
"qualified_count": "合格样本数", "qualified_rate": "合格率", "qualification_rate": "合格率",
|
||||||
|
"reviewed_events": "已复核事件数", "unreviewed_events": "未复核事件数", "sampling_method": "抽样方式",
|
||||||
|
"sampling_groups": "抽样分组", "sampling_coverage_percent": "抽样覆盖率", "outcomes": "复核结论分布",
|
||||||
|
"outcome": "复核结论", "independent": "独立复核", "disputed": "存在争议",
|
||||||
|
"confidence_interval": "置信区间", "confidence_interval_reason": "置信区间说明",
|
||||||
|
"unknown_patient_events": "患者身份未确认事件数", "repeated_patient_events": "重复患者事件数",
|
||||||
|
"invalid_row_count": "无效记录数", "metric": "统计指标",
|
||||||
|
}
|
||||||
|
|
||||||
|
ENUM_FIELDS = {
|
||||||
|
"status", "comparison_status", "coverage_status", "comparison_type", "validity", "dose_basis", "bases",
|
||||||
|
"match_type", "match", "side", "level", "kind", "source_kind", "type", "purpose", "transfer_method",
|
||||||
|
"sample_status", "aggregation_status", "history_versioning", "sampling_method", "outcome", "unit_policy",
|
||||||
|
"model_key", "model", "unit", "formula_type",
|
||||||
|
}
|
||||||
|
REASON_FIELDS = {
|
||||||
|
"reason", "reason_message", "reason_code", "error_code", "error_message", "code", "message",
|
||||||
|
"missing", "missing_information", "baseline_exclusion_reasons", "excluded_reasons", "exclusion_reasons", "exclusion_reason",
|
||||||
|
}
|
||||||
|
SOURCE_FIELDS = {
|
||||||
|
"source", "source_id", "source_ids", "file_id", "file_ids", "evidence_file_ids", "covered_source_ids",
|
||||||
|
"evidence_references", "redaction_manifest",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MACHINE_KEY = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+\Z")
|
||||||
|
_UPPER_CODE = re.compile(r"(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![A-Za-z0-9_])")
|
||||||
|
_SOURCE_ID = re.compile(r"([a-z][a-z0-9_]*)[::]([^\s,,;;:<>]+)\Z")
|
||||||
|
_KNOWN_SOURCE_IN_TEXT = re.compile(r"(?<![A-Za-z0-9_])(" + "|".join(SOURCE_LABELS) + r")[::]([A-Za-z0-9]+)(?![A-Za-z0-9_])")
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text(value: Any) -> str:
|
||||||
|
if value is None or value == "":
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "是" if value else "否"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def source_text(value: Any, fields: Mapping[str, str]) -> str:
|
||||||
|
"""Localize a source prefix, preserving its entire numeric or opaque ID."""
|
||||||
|
text = plain_text(value)
|
||||||
|
if text in SOURCE_LABELS:
|
||||||
|
return SOURCE_LABELS[text]
|
||||||
|
if text.startswith("clinical."):
|
||||||
|
field = text.removeprefix("clinical.")
|
||||||
|
return "临床资料 · " + fields.get(field, "待核对项目")
|
||||||
|
match = _SOURCE_ID.fullmatch(text)
|
||||||
|
if match:
|
||||||
|
prefix, identifier = match.groups()
|
||||||
|
# Redaction manifests append a field name after the numeric source ID.
|
||||||
|
if ":" in identifier:
|
||||||
|
identifier, field = identifier.split(":", 1)
|
||||||
|
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier}) · {fields.get(field, '待核对字段')}"
|
||||||
|
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier})"
|
||||||
|
if _MACHINE_KEY.fullmatch(text) or re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", text):
|
||||||
|
return "来源类型待核对"
|
||||||
|
return _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
|
||||||
|
|
||||||
|
|
||||||
|
def system_text(value: Any, labels: Mapping[str, str], fields: Mapping[str, str], *, strict: bool = False) -> str:
|
||||||
|
"""Translate metadata, including historical ``CODE: source:id`` gap strings."""
|
||||||
|
text = plain_text(value)
|
||||||
|
if "\n" in text:
|
||||||
|
return "\n".join(system_text(line, labels, fields, strict=strict) for line in text.split("\n"))
|
||||||
|
if text in labels:
|
||||||
|
return labels[text]
|
||||||
|
if text in SOURCE_LABELS:
|
||||||
|
return SOURCE_LABELS[text]
|
||||||
|
if text in fields:
|
||||||
|
return fields[text]
|
||||||
|
# Only a technical prefix permits parsing the rest as a source identifier.
|
||||||
|
compound = re.fullmatch(r"([A-Za-z][A-Za-z0-9_]+)\s*[::]\s*(.*)", text, re.DOTALL)
|
||||||
|
if compound and (compound[1] in labels or _MACHINE_KEY.fullmatch(compound[1])):
|
||||||
|
prefix = labels.get(compound[1], "未识别的系统原因,请联系管理员核对")
|
||||||
|
return prefix + ":" + source_text(compound[2], fields)
|
||||||
|
if _MACHINE_KEY.fullmatch(text):
|
||||||
|
return "未识别的系统标识,请联系管理员核对"
|
||||||
|
# Historic reports can include a known source/code inside a Chinese gap explanation.
|
||||||
|
text = _UPPER_CODE.sub(lambda match: labels.get(match[0], "未识别的系统原因,请联系管理员核对"), text)
|
||||||
|
text = _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
|
||||||
|
if strict and re.fullmatch(r"[A-Za-z][A-Za-z0-9 ._-]*", text):
|
||||||
|
return "未识别的系统状态,请联系管理员核对"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def field_text(key: Any, fields: Mapping[str, str], labels: Mapping[str, str]) -> str:
|
||||||
|
text = str(key)
|
||||||
|
if text in fields:
|
||||||
|
return fields[text]
|
||||||
|
if text.endswith("_count") and text.removesuffix("_count") in SOURCE_LABELS:
|
||||||
|
return SOURCE_LABELS[text.removesuffix("_count")] + "数"
|
||||||
|
translated = system_text(text, labels, fields)
|
||||||
|
if translated != text:
|
||||||
|
return translated
|
||||||
|
if text in SOURCE_LABELS or text.startswith("clinical.") or _SOURCE_ID.fullmatch(text):
|
||||||
|
return source_text(text, fields)
|
||||||
|
return "其他字段(待核对)" if re.search(r"[A-Za-z]", text) and not re.search(r"[\u4e00-\u9fff]", text) else text
|
||||||
|
|
||||||
|
|
||||||
|
def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mapping[str, str]) -> str:
|
||||||
|
if field in SOURCE_FIELDS:
|
||||||
|
return source_text(value, fields)
|
||||||
|
if field == "field":
|
||||||
|
return field_text(value, fields, labels)
|
||||||
|
if field == "coverage_status":
|
||||||
|
return {"partial": "资料不全", "pending": "资料待核对", "unavailable": "暂无覆盖信息"}.get(str(value)) or system_text(value, labels, fields, strict=True)
|
||||||
|
if field == "history_versioning" and value == "unavailable":
|
||||||
|
return "历史版本无法核验"
|
||||||
|
if field == "formula_type" and value == "auxiliary":
|
||||||
|
return "辅方"
|
||||||
|
if field.endswith("version") or field.endswith("versions"):
|
||||||
|
text = plain_text(value)
|
||||||
|
for prefix, name in (
|
||||||
|
("manual-prescription-independent-v", "手动处方独立分析"),
|
||||||
|
("manual-prescription-available-evidence-v", "手动处方已读资料分析"),
|
||||||
|
("prescription-soft-dice-v", "处方药味剂量一致度算法"),
|
||||||
|
("prescription-evidence-v", "处方证据资料格式"),
|
||||||
|
("prescription-source-access-v", "处方来源权限格式"),
|
||||||
|
):
|
||||||
|
if text.startswith(prefix) and re.fullmatch(r"\d+(?:\.\d+)*", text.removeprefix(prefix)):
|
||||||
|
return f"{name} · 第 {text.removeprefix(prefix)} 版"
|
||||||
|
return text
|
||||||
|
if field in ENUM_FIELDS:
|
||||||
|
return system_text(value, labels, fields, strict=True)
|
||||||
|
if field in REASON_FIELDS:
|
||||||
|
return system_text(value, labels, fields, strict=field in {"code", "error_code", "reason_code"})
|
||||||
|
if field.endswith(("_status", "_state", "_code")):
|
||||||
|
return system_text(value, labels, fields, strict=True)
|
||||||
|
if field and field not in fields:
|
||||||
|
return system_text(value, labels, fields)
|
||||||
|
return plain_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
STATE_LABELS = {
|
||||||
|
"blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果",
|
||||||
|
"not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析",
|
||||||
|
"preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联",
|
||||||
|
"queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写",
|
||||||
|
"waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中",
|
||||||
|
"retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成",
|
||||||
|
"partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消",
|
||||||
|
"stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效",
|
||||||
|
"prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销",
|
||||||
|
"current": "当前版本", "valid": "当前有效", "complete": "资料清单完整",
|
||||||
|
"incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认",
|
||||||
|
"needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联",
|
||||||
|
"independent_baseline": "独立基线", "baseline": "独立基线",
|
||||||
|
"latest_context": "最新资料对照", "supplemental": "最新资料对照",
|
||||||
|
"assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订",
|
||||||
|
"non_independent": "非独立对照", "auxiliary": "辅助复核",
|
||||||
|
"comparable": "可比", "not_comparable": "不可比",
|
||||||
|
"available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药",
|
||||||
|
"withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料",
|
||||||
|
"not_adopted": "不采纳", "reviewed": "已复核",
|
||||||
|
"per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方",
|
||||||
|
"insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计",
|
||||||
|
}
|
||||||
|
FIELD_LABELS = {
|
||||||
|
"summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证",
|
||||||
|
"diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险",
|
||||||
|
"evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明",
|
||||||
|
"risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据",
|
||||||
|
"sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态",
|
||||||
|
"reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名",
|
||||||
|
"processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准",
|
||||||
|
"formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量",
|
||||||
|
"doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量",
|
||||||
|
"contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况",
|
||||||
|
"prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味",
|
||||||
|
"dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次",
|
||||||
|
"usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径",
|
||||||
|
"rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录",
|
||||||
|
"algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本",
|
||||||
|
"prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数",
|
||||||
|
"matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总",
|
||||||
|
"cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见",
|
||||||
|
"match_type": "增减药项", "administration_route": "给药途径", "group": "用药组",
|
||||||
|
"delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
|
||||||
|
"diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数",
|
||||||
|
"daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数",
|
||||||
|
"files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号",
|
||||||
|
"complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口",
|
||||||
|
"baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明",
|
||||||
|
"versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数",
|
||||||
|
"distribution": "一致度分布", "sample_status": "样本说明",
|
||||||
|
}
|
||||||
|
STATE_LABELS.update(SYSTEM_LABELS)
|
||||||
|
FIELD_LABELS.update(EXTRA_FIELD_LABELS)
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
"""Honest presentation of server checkpoints, without fabricated totals or ETA."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ACTIVE_STATES = {"pending", "preparing", "queued", "waiting", "waiting_sources", "waiting_transcript", "waiting_transcription", "running", "processing", "retrying", "retry_wait"}
|
||||||
|
SUCCESS_STATES = {"succeeded", "completed", "success"}
|
||||||
|
TERMINAL_STATES = SUCCESS_STATES | {"partial", "failed", "cancelled", "canceled", "blocked", "stale", "superseded", "invalid", "revoked", "deleted", "voided"}
|
||||||
|
STAGES = {
|
||||||
|
"preparing": "准备资料", "waiting_sources": "等待转写与资料", "queued": "排队等待处理",
|
||||||
|
"text": "分析文字资料", "files": "分析附件", "reduce": "汇总资料要点",
|
||||||
|
"final": "生成完整报告", "validating": "校验报告", "comparing": "计算用药对照",
|
||||||
|
"completed": "已完成", "retry_wait": "等待重试", "failed": "处理失败", "cancelled": "已取消",
|
||||||
|
"unknown": "等待阶段详情",
|
||||||
|
}
|
||||||
|
COMPACT_STAGES = {"preparing": "准备资料", "waiting_sources": "等待资料", "queued": "排队中", "text": "文字",
|
||||||
|
"files": "附件", "reduce": "汇总", "final": "生成报告", "validating": "校验", "comparing": "用药对照",
|
||||||
|
"completed": "已完成", "retry_wait": "待重试", "failed": "失败", "cancelled": "已取消", "unknown": "处理中"}
|
||||||
|
STATUS_STAGE = {"pending": "queued", "waiting": "waiting_sources", "waiting_transcript": "waiting_sources",
|
||||||
|
"waiting_transcription": "waiting_sources", "retrying": "retry_wait",
|
||||||
|
**{state: "completed" for state in SUCCESS_STATES}, "canceled": "cancelled", "partial": "completed", "blocked": "failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: Any) -> int | None:
|
||||||
|
# Counts/timestamps are integer contract fields, not arbitrary numeric text.
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def duration(seconds: int) -> str:
|
||||||
|
seconds = max(0, seconds)
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds} 秒"
|
||||||
|
if seconds < 3600:
|
||||||
|
return f"{seconds // 60} 分 {seconds % 60:02d} 秒"
|
||||||
|
return f"{seconds // 3600} 小时 {seconds % 3600 // 60:02d} 分"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProgressView:
|
||||||
|
stage: str
|
||||||
|
headline: str
|
||||||
|
detail: str
|
||||||
|
completed: int | None = None
|
||||||
|
total: int | None = None
|
||||||
|
busy: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def progress_view(owner: Any, *, fallback_status: str = "", seconds: int = 0, live: bool = True) -> ProgressView:
|
||||||
|
data = _mapping(owner)
|
||||||
|
progress = _mapping(data.get("progress"))
|
||||||
|
status = str(data.get("status") or fallback_status)
|
||||||
|
stage = str(progress.get("stage") or STATUS_STAGE.get(status, status))
|
||||||
|
if stage not in STAGES:
|
||||||
|
stage = "unknown"
|
||||||
|
terminal = status in TERMINAL_STATES
|
||||||
|
if terminal:
|
||||||
|
stage = STATUS_STAGE.get(status, status)
|
||||||
|
if stage not in STAGES:
|
||||||
|
stage = "unknown"
|
||||||
|
active = status in ACTIVE_STATES and not terminal and stage not in {"completed", "failed", "cancelled"}
|
||||||
|
advance = max(0, seconds) if active else 0
|
||||||
|
headline = STAGES[stage]
|
||||||
|
if status == "partial":
|
||||||
|
headline = "部分模型已完成"
|
||||||
|
if stage == "unknown":
|
||||||
|
headline = "正在处理,等待阶段详情" if active else "暂无处理进度"
|
||||||
|
completed = _integer(progress.get("completed_units"))
|
||||||
|
total = _integer(progress.get("total_units"))
|
||||||
|
if stage not in {"text", "files", "reduce"} or total is None or not 0 < total <= 1_000_000 or completed is None or completed > total:
|
||||||
|
completed = total = None
|
||||||
|
if total is not None:
|
||||||
|
headline += f" · 本阶段 {completed}/{total} 组"
|
||||||
|
|
||||||
|
details = []
|
||||||
|
attempt = _integer(progress.get("attempt"))
|
||||||
|
if attempt is not None and 0 < attempt <= 1_000_000:
|
||||||
|
details.append(f"第 {attempt} 次尝试")
|
||||||
|
elapsed = _integer(progress.get("elapsed_seconds"))
|
||||||
|
stage_elapsed = _integer(progress.get("stage_elapsed_seconds"))
|
||||||
|
if elapsed is not None:
|
||||||
|
# Retry metadata freezes the previous attempt's duration. Only its
|
||||||
|
# scheduling countdown and update age continue between server polls.
|
||||||
|
details.append("已用时 " + duration(elapsed + (0 if stage == "retry_wait" else advance)))
|
||||||
|
if stage_elapsed is not None and active and stage != "retry_wait":
|
||||||
|
details.append("本阶段 " + duration(stage_elapsed + advance))
|
||||||
|
remaining = _integer(progress.get("wait_remaining_seconds"))
|
||||||
|
if stage in {"preparing", "waiting_sources", "retry_wait"} and remaining is not None and active:
|
||||||
|
remaining = max(0, remaining - advance)
|
||||||
|
details.append("资料等待窗口剩余 " + duration(remaining) if remaining else "等待窗口已到,等待服务端确认")
|
||||||
|
if stage == "retry_wait":
|
||||||
|
details[-1] = "距下次重试 " + duration(remaining) if remaining else "重试时间已到,等待服务端确认"
|
||||||
|
server_time = _integer(progress.get("server_time"))
|
||||||
|
updated_at = _integer(progress.get("updated_at"))
|
||||||
|
if active and server_time is not None and updated_at is not None and updated_at > 0:
|
||||||
|
details.append("阶段更新于 " + duration(max(0, server_time - updated_at) + advance) + "前")
|
||||||
|
if not progress and active:
|
||||||
|
details.append("服务端暂未提供分阶段进度")
|
||||||
|
notice = progress.get("notice")
|
||||||
|
terminal_notice = stage in {"failed", "cancelled"} and progress.get("stage") == stage and progress.get("phase") == "failed"
|
||||||
|
if isinstance(notice, str) and notice.strip() and (not terminal or terminal_notice):
|
||||||
|
details.append(notice.strip())
|
||||||
|
elif stage in {"final", "text", "files", "reduce"} and active:
|
||||||
|
details.append("等待模型响应;耗时取决于资料量与模型服务")
|
||||||
|
return ProgressView(stage, headline, " · ".join(details), completed, total, live and active)
|
||||||
|
|
||||||
|
|
||||||
|
def flow_text(batch: Any) -> str:
|
||||||
|
data = _mapping(batch)
|
||||||
|
status = data.get("status")
|
||||||
|
if not data:
|
||||||
|
return "准备资料 → 双模型分析 → 用药对照 → 完成"
|
||||||
|
if data.get("validity") not in (None, "", "current", "valid"):
|
||||||
|
return "此批次已失效 · 以下为最后保存的处理记录"
|
||||||
|
models = [_mapping(_mapping(data.get("models")).get(key)) for key in ("qwen", "openai")]
|
||||||
|
complete = sum(model.get("status") in SUCCESS_STATES for model in models)
|
||||||
|
failed = sum(model.get("status") == "failed" for model in models)
|
||||||
|
stages = [progress_view(model).stage for model in models]
|
||||||
|
batch_stage = progress_view(data).stage
|
||||||
|
if status in {"failed", "cancelled", "canceled", "blocked"}:
|
||||||
|
return f"处理已停止 · {complete}/2 个模型完成" + (f" · {failed} 个模型失败" if failed else "")
|
||||||
|
if batch_stage in {"preparing", "waiting_sources"}:
|
||||||
|
return "准备资料:进行中 → 双模型分析:待开始 → 用药对照:待开始 → 完成:待处理"
|
||||||
|
prepared = "已完成" if any(models) else "待确认"
|
||||||
|
comparisons = sum(model.get("status") in SUCCESS_STATES and bool(model.get("comparison")) for model in models)
|
||||||
|
compare_state = f"{comparisons}/2 已处理" if comparisons else "进行中" if "comparing" in stages else "待处理"
|
||||||
|
analysis = f"{complete}/2 完成" + (f",{failed} 个失败" if failed else ",进行中" if status not in TERMINAL_STATES else "")
|
||||||
|
end = "已完成" if complete == 2 else "部分完成" if status == "partial" else "待处理"
|
||||||
|
return f"准备资料:{prepared} → 双模型分析:{analysis} → 用药对照:{compare_state} → 完成:{end}"
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""The analysis console's palette, and the switch between its dark and light themes.
|
||||||
|
|
||||||
|
The prescription analysis window has its own theme, separate from the workstation's light chrome:
|
||||||
|
tech blue for the interface itself, and two model hues (cyan for 千问, violet for OpenAI) that stay
|
||||||
|
apart from the interface colour and from each other. Severity keeps amber and rose, so a reading
|
||||||
|
never depends on hue alone.
|
||||||
|
|
||||||
|
``CONSOLE`` is the active palette and is mutated in place by :func:`use_theme`, so every module
|
||||||
|
that imported it sees the new values. Anything derived from it — a stylesheet string, a colour
|
||||||
|
constant — must be rebuilt in a callback registered through :func:`on_theme_changed`.
|
||||||
|
|
||||||
|
Key names mirror ``reception_style.TECH_BLUE`` so a widget can take either palette.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
DARK: dict[str, str] = {
|
||||||
|
# ground and cards
|
||||||
|
"canvas": "#080D18",
|
||||||
|
"canvas_soft": "#0C1322",
|
||||||
|
"surface": "#0F1726",
|
||||||
|
"surface_2": "#131D2E",
|
||||||
|
"raised": "#1A2637",
|
||||||
|
"line": "#22304A",
|
||||||
|
"line_soft": "#18233A",
|
||||||
|
# type
|
||||||
|
"heading": "#E8EFFB",
|
||||||
|
"text": "#B6C5DA",
|
||||||
|
"muted": "#8195AF",
|
||||||
|
"faint": "#6A7F99",
|
||||||
|
"selected_text": "#E8EFFB",
|
||||||
|
# interface colour
|
||||||
|
"accent": "#2E7BF6",
|
||||||
|
"accent_text": "#6BA5FF",
|
||||||
|
"accent_pressed": "#1A5FD0",
|
||||||
|
"selection": "#142645",
|
||||||
|
"selection_line": "#1B3E77",
|
||||||
|
# model hues
|
||||||
|
"qwen": "#17BFDD",
|
||||||
|
"qwen_text": "#55D8EE",
|
||||||
|
"qwen_dim": "#10303C",
|
||||||
|
"openai": "#9B7BFF",
|
||||||
|
"openai_text": "#B69FFF",
|
||||||
|
"openai_dim": "#241F48",
|
||||||
|
# severity
|
||||||
|
"amber": "#F5B942",
|
||||||
|
"amber_text": "#F8C661",
|
||||||
|
"amber_dim": "#2C2415",
|
||||||
|
"rose": "#F4697A",
|
||||||
|
"rose_text": "#F88694",
|
||||||
|
"rose_dim": "#2C1620",
|
||||||
|
"ok": "#2E7BF6",
|
||||||
|
"zebra": "#0E1626",
|
||||||
|
"grid_line": "#191F2C",
|
||||||
|
}
|
||||||
|
|
||||||
|
LIGHT: dict[str, str] = {
|
||||||
|
"canvas": "#F1F5FC",
|
||||||
|
"canvas_soft": "#E7EEFA",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface_2": "#F5F8FE",
|
||||||
|
"raised": "#E8EFFA",
|
||||||
|
"line": "#D5E1F2",
|
||||||
|
"line_soft": "#E3EBF8",
|
||||||
|
"heading": "#0A182E",
|
||||||
|
"text": "#2A3C56",
|
||||||
|
"muted": "#54697F",
|
||||||
|
"faint": "#7A8DA3",
|
||||||
|
"selected_text": "#0A182E",
|
||||||
|
"accent": "#1A5FD0",
|
||||||
|
"accent_text": "#124DAE",
|
||||||
|
"accent_pressed": "#0E3F90",
|
||||||
|
"selection": "#E8EFFA",
|
||||||
|
"selection_line": "#AEC7EE",
|
||||||
|
"qwen": "#0C89AC",
|
||||||
|
"qwen_text": "#086C88",
|
||||||
|
"qwen_dim": "#E2F5FA",
|
||||||
|
"openai": "#6B4AE0",
|
||||||
|
"openai_text": "#5537C6",
|
||||||
|
"openai_dim": "#EDE8FE",
|
||||||
|
"amber": "#B4791A",
|
||||||
|
"amber_text": "#8F5F0B",
|
||||||
|
"amber_dim": "#FCF2DF",
|
||||||
|
"rose": "#D0435A",
|
||||||
|
"rose_text": "#AE3149",
|
||||||
|
"rose_dim": "#FCE9EC",
|
||||||
|
"ok": "#1A5FD0",
|
||||||
|
"zebra": "#FAFBFE",
|
||||||
|
"grid_line": "#DDE4EE",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSOLE: dict[str, str] = dict(DARK)
|
||||||
|
_THEME = "dark"
|
||||||
|
_LISTENERS: list[Callable[[], None]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def current_theme() -> str:
|
||||||
|
return _THEME
|
||||||
|
|
||||||
|
|
||||||
|
def on_theme_changed(callback: Callable[[], None]) -> Callable[[], None]:
|
||||||
|
"""Register a rebuild for anything derived from the palette; returns the callback."""
|
||||||
|
|
||||||
|
_LISTENERS.append(callback)
|
||||||
|
return callback
|
||||||
|
|
||||||
|
|
||||||
|
def use_theme(name: str) -> str:
|
||||||
|
"""Switch the active palette in place and let every derived value rebuild itself."""
|
||||||
|
|
||||||
|
global _THEME
|
||||||
|
palette = LIGHT if name == "light" else DARK
|
||||||
|
_THEME = "light" if name == "light" else "dark"
|
||||||
|
CONSOLE.clear()
|
||||||
|
CONSOLE.update(palette)
|
||||||
|
_rebuild()
|
||||||
|
return _THEME
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_theme() -> str:
|
||||||
|
return use_theme("light" if _THEME == "dark" else "dark")
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild() -> None:
|
||||||
|
for callback in list(_LISTENERS):
|
||||||
|
callback()
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_HUE = {"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]}
|
||||||
|
MODEL_TEXT = {"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]}
|
||||||
|
MODEL_DIM = {"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]}
|
||||||
|
|
||||||
|
|
||||||
|
@on_theme_changed
|
||||||
|
def _rebuild_model_hues() -> None:
|
||||||
|
MODEL_HUE.update({"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]})
|
||||||
|
MODEL_TEXT.update({"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]})
|
||||||
|
MODEL_DIM.update({"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]})
|
||||||
|
|
||||||
|
|
||||||
|
RADIUS = 10
|
||||||
|
RADIUS_SM = 7
|
||||||
|
|
||||||
|
# The design sets numbers in a condensed face so columns of figures line up; the fallbacks keep
|
||||||
|
# the same tabular behaviour when Bahnschrift is missing.
|
||||||
|
NUM_FAMILIES = ("Bahnschrift", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei")
|
||||||
|
|
||||||
|
|
||||||
|
def num_font(size: int, *, weight: QFont.Weight | None = None) -> QFont:
|
||||||
|
"""A tabular face for figures, at the pixel size the design states."""
|
||||||
|
|
||||||
|
font = QFont()
|
||||||
|
font.setFamilies(list(NUM_FAMILIES))
|
||||||
|
font.setPixelSize(size)
|
||||||
|
if weight is not None:
|
||||||
|
font.setWeight(weight)
|
||||||
|
font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
# Windows draws the title bar itself, so the console's dark ground stops at the frame unless the
|
||||||
|
# window asks DWM for a matching caption. Windows 11 (build 22000+) honours these attributes;
|
||||||
|
# anywhere else the call fails and the native bar is left as it is.
|
||||||
|
_DWMWA_USE_IMMERSIVE_DARK_MODE = 20
|
||||||
|
_DWMWA_BORDER_COLOR = 34
|
||||||
|
_DWMWA_CAPTION_COLOR = 35
|
||||||
|
_DWMWA_TEXT_COLOR = 36
|
||||||
|
|
||||||
|
|
||||||
|
def _colorref(value: str) -> int:
|
||||||
|
"""A ``#RRGGBB`` string as the ``0x00BBGGRR`` integer DWM expects."""
|
||||||
|
|
||||||
|
colour = value.lstrip("#")
|
||||||
|
red, green, blue = (int(colour[index:index + 2], 16) for index in (0, 2, 4))
|
||||||
|
return (blue << 16) | (green << 8) | red
|
||||||
|
|
||||||
|
|
||||||
|
def apply_window_chrome(widget: Any) -> bool:
|
||||||
|
"""Paint the native title bar in the palette that is active right now.
|
||||||
|
|
||||||
|
Returns whether DWM accepted the change, so a caller can tell "not Windows 11" from "done".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return False
|
||||||
|
handle = int(widget.winId())
|
||||||
|
if not handle:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
dwm = ctypes.windll.dwmapi
|
||||||
|
except (AttributeError, OSError): # pragma: no cover - not Windows
|
||||||
|
return False
|
||||||
|
dark = ctypes.c_int(1 if _THEME == "dark" else 0)
|
||||||
|
caption = ctypes.c_uint(_colorref(CONSOLE["canvas"]))
|
||||||
|
text = ctypes.c_uint(_colorref(CONSOLE["heading"]))
|
||||||
|
border = ctypes.c_uint(_colorref(CONSOLE["line"]))
|
||||||
|
applied = False
|
||||||
|
for attribute, value in ((_DWMWA_USE_IMMERSIVE_DARK_MODE, dark), (_DWMWA_CAPTION_COLOR, caption),
|
||||||
|
(_DWMWA_TEXT_COLOR, text), (_DWMWA_BORDER_COLOR, border)):
|
||||||
|
result = dwm.DwmSetWindowAttribute(ctypes.c_void_p(handle), ctypes.c_int(attribute),
|
||||||
|
ctypes.byref(value), ctypes.sizeof(value))
|
||||||
|
applied = applied or result == 0
|
||||||
|
return applied
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QBuffer,
|
QBuffer,
|
||||||
@@ -2658,6 +2659,8 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
self._source = _mapping(prescription)
|
self._source = _mapping(prescription)
|
||||||
|
self._save_fingerprint = ""
|
||||||
|
self._save_request_key = ""
|
||||||
self._loading_data = False
|
self._loading_data = False
|
||||||
self._linked_order_generation = 0
|
self._linked_order_generation = 0
|
||||||
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
|
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
|
||||||
@@ -3786,6 +3789,7 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
"audit_remark",
|
"audit_remark",
|
||||||
"business_prescription_audit_rejected",
|
"business_prescription_audit_rejected",
|
||||||
"business_prescription_audit_remark",
|
"business_prescription_audit_remark",
|
||||||
|
"ai_assisted",
|
||||||
)
|
)
|
||||||
result = {key: self._source.get(key) for key in hidden_keys if key in self._source}
|
result = {key: self._source.get(key) for key in hidden_keys if key in self._source}
|
||||||
if self.mode == "add":
|
if self.mode == "add":
|
||||||
@@ -3838,6 +3842,20 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
result["dosage_amount"] = dosage_amount
|
result["dosage_amount"] = dosage_amount
|
||||||
else:
|
else:
|
||||||
result.pop("dosage_amount", None)
|
result.pop("dosage_amount", None)
|
||||||
|
# This editor has no independent-assistance attestation control. Only
|
||||||
|
# retain positive evidence of AI exposure; a seed's false is not a new
|
||||||
|
# doctor-confirmed statement that this submission was unassisted.
|
||||||
|
if result.get("ai_assisted") in (True, 1, "1", "true"):
|
||||||
|
result["ai_assisted"] = True
|
||||||
|
else:
|
||||||
|
result.pop("ai_assisted", None)
|
||||||
|
# A repeated submit of unchanged editor content keeps its idempotency key.
|
||||||
|
# Unknown AI exposure is omitted, rather than falsely asserted as False.
|
||||||
|
fingerprint = json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
if fingerprint != self._save_fingerprint:
|
||||||
|
self._save_fingerprint = fingerprint
|
||||||
|
self._save_request_key = str(uuid4())
|
||||||
|
result["request_key"] = self._save_request_key
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _herb_validation_label(self, global_index: int) -> str:
|
def _herb_validation_label(self, global_index: int) -> str:
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core.appointment_modes import (
|
||||||
|
appointment_type_description,
|
||||||
|
appointment_type_value,
|
||||||
|
can_appointment_video,
|
||||||
|
)
|
||||||
from ..appointments_style import appointments_stylesheet
|
from ..appointments_style import appointments_stylesheet
|
||||||
from ..dialogs import DiagnosisDialog
|
from ..dialogs import DiagnosisDialog
|
||||||
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
||||||
@@ -312,7 +317,8 @@ def _appointment_info_cell(_value: Any, row: Any) -> str:
|
|||||||
channel = display_text(
|
channel = display_text(
|
||||||
first_value(row, "channel_name", "channel_source_name", "source_name"), "—"
|
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:
|
def _revisit_cell(_value: Any, row: Any) -> str:
|
||||||
@@ -1273,7 +1279,16 @@ class AppointmentsPage(QWidget):
|
|||||||
host,
|
host,
|
||||||
)
|
)
|
||||||
date_label.setProperty("tableAppointmentMeta", True)
|
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(
|
channel = display_text(
|
||||||
first_value(row, "channel_name", "channel_source_name", "source_name"),
|
first_value(row, "channel_name", "channel_source_name", "source_name"),
|
||||||
"—",
|
"—",
|
||||||
@@ -1310,7 +1325,8 @@ class AppointmentsPage(QWidget):
|
|||||||
layout.setContentsMargins(5, 0, 5, 0)
|
layout.setContentsMargins(5, 0, 5, 0)
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
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.setObjectName("AppointmentImConsultButton")
|
||||||
button.setProperty("appointmentImAction", True)
|
button.setProperty("appointmentImAction", True)
|
||||||
patient_name = display_text(first_value(row, "patient_name"), "患者")
|
patient_name = display_text(first_value(row, "patient_name"), "患者")
|
||||||
@@ -1338,7 +1354,8 @@ class AppointmentsPage(QWidget):
|
|||||||
elif status_error:
|
elif status_error:
|
||||||
tooltip = status_error
|
tooltip = status_error
|
||||||
else:
|
else:
|
||||||
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
|
tooltip = ("打开图文问诊,可发送文字、图片和文件;不支持音视频通话"
|
||||||
|
if is_text else "打开患者 IM,可发送消息;通话能力由本次挂号决定")
|
||||||
button.setToolTip(tooltip)
|
button.setToolTip(tooltip)
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
lambda _checked=False, source=row: self._run_video_row_action(
|
lambda _checked=False, source=row: self._run_video_row_action(
|
||||||
@@ -1427,6 +1444,7 @@ class AppointmentsPage(QWidget):
|
|||||||
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||||
self.qr_button.setEnabled(
|
self.qr_button.setEnabled(
|
||||||
not_completed
|
not_completed
|
||||||
|
and can_appointment_video(appointment_type_value(row))
|
||||||
and _video_patient_id(row) > 0
|
and _video_patient_id(row) > 0
|
||||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||||
)
|
)
|
||||||
@@ -1450,6 +1468,7 @@ class AppointmentsPage(QWidget):
|
|||||||
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||||
self.toolbar_qr_button.setEnabled(
|
self.toolbar_qr_button.setEnabled(
|
||||||
not_completed
|
not_completed
|
||||||
|
and can_appointment_video(appointment_type_value(row))
|
||||||
and _video_patient_id(row) > 0
|
and _video_patient_id(row) > 0
|
||||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||||
)
|
)
|
||||||
@@ -1538,6 +1557,7 @@ class AppointmentsPage(QWidget):
|
|||||||
{
|
{
|
||||||
"source": "appointments",
|
"source": "appointments",
|
||||||
"appointment_id": appointment_id,
|
"appointment_id": appointment_id,
|
||||||
|
"appointment_type": appointment_type_value(row),
|
||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"patient_name": first_value(row, "patient_name", default="患者"),
|
"patient_name": first_value(row, "patient_name", default="患者"),
|
||||||
@@ -1562,6 +1582,9 @@ class AppointmentsPage(QWidget):
|
|||||||
row = self._current_row()
|
row = self._current_row()
|
||||||
if row is None or _status_value(row) == 3:
|
if row is None or _status_value(row) == 3:
|
||||||
return
|
return
|
||||||
|
if not can_appointment_video(appointment_type_value(row)):
|
||||||
|
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
|
||||||
|
return
|
||||||
diagnosis_id = _diagnosis_id(row)
|
diagnosis_id = _diagnosis_id(row)
|
||||||
patient_id = _video_patient_id(row)
|
patient_id = _video_patient_id(row)
|
||||||
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
||||||
@@ -2214,7 +2237,7 @@ class AppointmentsPage(QWidget):
|
|||||||
("时段", period_text),
|
("时段", period_text),
|
||||||
(
|
(
|
||||||
"预约类型",
|
"预约类型",
|
||||||
first_value(detail, "appointment_type_desc", "appointment_type_text"),
|
appointment_type_description(appointment_type_value(detail)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||||
from .. import icons
|
from .. import icons
|
||||||
from ..consultations_style import consultations_stylesheet
|
from ..consultations_style import consultations_stylesheet
|
||||||
from ..diagnosis_index_widgets import (
|
from ..diagnosis_index_widgets import (
|
||||||
@@ -226,7 +227,7 @@ def _appointment_status(record: Any) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _appointment_id(record: Any) -> int:
|
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:
|
if value is None:
|
||||||
value = first_value(get_value(record, "latest_appointment", None), "id", default=None)
|
value = first_value(get_value(record, "latest_appointment", None), "id", default=None)
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -290,6 +291,7 @@ def _video_payload(record: Any) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"source": "consultations",
|
"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"),
|
"patient_id": first_value(record, "patient_id", "source_patient_id"),
|
||||||
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
|
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
|
||||||
"patient_name": first_value(record, "patient_name", default="患者"),
|
"patient_name": first_value(record, "patient_name", default="患者"),
|
||||||
@@ -2656,6 +2658,10 @@ class ConsultationsPage(QWidget):
|
|||||||
).strip()
|
).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(
|
self._request_qr(
|
||||||
capability="video_qr",
|
capability="video_qr",
|
||||||
permission="tcm.diagnosis/videoQr",
|
permission="tcm.diagnosis/videoQr",
|
||||||
@@ -3050,11 +3056,11 @@ class ConsultationsPage(QWidget):
|
|||||||
self.video_button.setEnabled(
|
self.video_button.setEnabled(
|
||||||
has_record
|
has_record
|
||||||
and is_video_available(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
|
and valid_ids
|
||||||
)
|
)
|
||||||
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
|
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.complete_toolbar_button.setEnabled(has_record)
|
||||||
self.case_toolbar_button.setEnabled(has_record)
|
self.case_toolbar_button.setEnabled(has_record)
|
||||||
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
|
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
|
||||||
@@ -3498,7 +3504,7 @@ class ConsultationsPage(QWidget):
|
|||||||
if record is None or not is_video_available(record):
|
if record is None or not is_video_available(record):
|
||||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||||
return
|
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")
|
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||||
return
|
return
|
||||||
payload = _video_payload(record)
|
payload = _video_payload(record)
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core.appointment_modes import (
|
||||||
|
APPOINTMENT_MODES,
|
||||||
|
appointment_type_description,
|
||||||
|
appointment_type_value,
|
||||||
|
)
|
||||||
from .. import icons
|
from .. import icons
|
||||||
from ..appointment_drawer import AppointmentDrawer
|
from ..appointment_drawer import AppointmentDrawer
|
||||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||||
@@ -499,7 +504,8 @@ class _LegacyAppointmentDialog(QDialog):
|
|||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setVerticalSpacing(10)
|
form.setVerticalSpacing(10)
|
||||||
self.appointment_type = QComboBox()
|
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)
|
form.addRow("预约类型 *", self.appointment_type)
|
||||||
self.channel_source = QComboBox()
|
self.channel_source = QComboBox()
|
||||||
self.channel_source.addItem("正在加载渠道…", "")
|
self.channel_source.addItem("正在加载渠道…", "")
|
||||||
@@ -1372,7 +1378,9 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
|||||||
date_text, separator, time_text = value.partition(" ")
|
date_text, separator, time_text = value.partition(" ")
|
||||||
if separator and time_text:
|
if separator and time_text:
|
||||||
draw(date_text, top)
|
draw(date_text, top)
|
||||||
draw(time_text, bottom)
|
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:
|
else:
|
||||||
draw(value, rect)
|
draw(value, rect)
|
||||||
else:
|
else:
|
||||||
@@ -3005,7 +3013,7 @@ class PatientProgressWorkspace(QWidget):
|
|||||||
"appointment_time",
|
"appointment_time",
|
||||||
"预约时间",
|
"预约时间",
|
||||||
95,
|
95,
|
||||||
lambda value, _row: display_text(value)[:5],
|
lambda value, row: f"{display_text(value)[:5]} · {appointment_type_description(appointment_type_value(row))}",
|
||||||
),
|
),
|
||||||
TableColumn(
|
TableColumn(
|
||||||
"ahead_count",
|
"ahead_count",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from html import escape
|
|||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
|
from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
@@ -34,6 +34,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
|
QMenu,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
@@ -49,6 +50,15 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .. import icons, motion
|
from .. import icons, motion
|
||||||
|
from ..dialogs.issued_prescription_ai import (
|
||||||
|
PrescriptionAiStatisticsDialog,
|
||||||
|
agreement_text,
|
||||||
|
batch_running,
|
||||||
|
can_open_issued_ai,
|
||||||
|
present_issued_prescription_ai,
|
||||||
|
state_text,
|
||||||
|
status_tooltip,
|
||||||
|
)
|
||||||
from ..dialogs.prescription import (
|
from ..dialogs.prescription import (
|
||||||
AuditPrescriptionDialog,
|
AuditPrescriptionDialog,
|
||||||
DiagnosisDetailDialog,
|
DiagnosisDetailDialog,
|
||||||
@@ -435,6 +445,21 @@ def _formula(value: Any) -> str:
|
|||||||
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
|
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blank_prescription(row: Any) -> bool:
|
||||||
|
if row is None or _truthy(get_value(row, "is_system_auto")):
|
||||||
|
return True
|
||||||
|
raw = getattr(row, "raw", None)
|
||||||
|
source = raw if isinstance(raw, Mapping) and raw else row
|
||||||
|
missing = object()
|
||||||
|
herbs = get_value(source, "herbs", missing)
|
||||||
|
# Compact historical rows may omit herbs; omission is not an empty prescription.
|
||||||
|
if herbs is missing:
|
||||||
|
return False
|
||||||
|
return not isinstance(herbs, (list, tuple)) or not any(
|
||||||
|
str(get_value(herb, "name", "") or "").strip() for herb in herbs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _order_warnings(row: Any) -> list[str]:
|
def _order_warnings(row: Any) -> list[str]:
|
||||||
"""Match the PC list's linked-order checks, including blank herb rows."""
|
"""Match the PC list's linked-order checks, including blank herb rows."""
|
||||||
|
|
||||||
@@ -877,7 +902,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
|
|||||||
painter.setPen(QColor(foreground))
|
painter.setPen(QColor(foreground))
|
||||||
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||||
elif column != 2:
|
elif column != 2:
|
||||||
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else [text]
|
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else text.splitlines() if column == 12 else [text]
|
||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
primary, secondary = lines[0], " · ".join(lines[1:]) if column == 5 else lines[1]
|
primary, secondary = lines[0], " · ".join(lines[1:]) if column == 5 else lines[1]
|
||||||
if column == 5:
|
if column == 5:
|
||||||
@@ -980,6 +1005,17 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._diagnosis_detail_generation = 0
|
self._diagnosis_detail_generation = 0
|
||||||
self._diagnosis_detail_target = 0
|
self._diagnosis_detail_target = 0
|
||||||
self._mutation_pending = False
|
self._mutation_pending = False
|
||||||
|
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
||||||
|
self._ai_enabled = False
|
||||||
|
self._ai_request_token = 0
|
||||||
|
self._ai_pending = False
|
||||||
|
self._ai_timer = QTimer(self)
|
||||||
|
self._ai_timer.setInterval(5000)
|
||||||
|
self._ai_timer.timeout.connect(self._load_ai_statuses)
|
||||||
|
self._ai_scroll_timer = QTimer(self)
|
||||||
|
self._ai_scroll_timer.setSingleShot(True)
|
||||||
|
self._ai_scroll_timer.setInterval(180)
|
||||||
|
self._ai_scroll_timer.timeout.connect(self._load_ai_statuses)
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(28, 16, 26, 8)
|
outer.setContentsMargins(28, 16, 26, 8)
|
||||||
@@ -1011,6 +1047,10 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
|
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
|
||||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||||
header.add_action(self.orders_button)
|
header.add_action(self.orders_button)
|
||||||
|
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
||||||
|
self.ai_statistics_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
|
||||||
|
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
||||||
|
header.add_action(self.ai_statistics_button)
|
||||||
self.add_button = QPushButton("新增处方", header)
|
self.add_button = QPushButton("新增处方", header)
|
||||||
self.add_button.setObjectName("PrescriptionAddButton")
|
self.add_button.setObjectName("PrescriptionAddButton")
|
||||||
self.add_button.setMinimumWidth(122)
|
self.add_button.setMinimumWidth(122)
|
||||||
@@ -1157,6 +1197,9 @@ class PrescriptionsPage(QWidget):
|
|||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
|
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
|
||||||
toolbar.addWidget(self.view_button)
|
toolbar.addWidget(self.view_button)
|
||||||
|
self.ai_report_button = self._action_button("AI 报告", "tcm.prescriptionAi/reports", self._open_ai_report)
|
||||||
|
self.ai_report_button.hide()
|
||||||
|
toolbar.addWidget(self.ai_report_button)
|
||||||
self.patch_button = self._action_button(
|
self.patch_button = self._action_button(
|
||||||
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
|
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
|
||||||
)
|
)
|
||||||
@@ -1183,6 +1226,14 @@ class PrescriptionsPage(QWidget):
|
|||||||
refresh.clicked.connect(self.refresh)
|
refresh.clicked.connect(self.refresh)
|
||||||
toolbar.addWidget(refresh)
|
toolbar.addWidget(refresh)
|
||||||
layout.addWidget(toolbar_scroll)
|
layout.addWidget(toolbar_scroll)
|
||||||
|
self.ai_status_notice = QLabel("AI 分析:正在检查服务状态。", card)
|
||||||
|
self.ai_status_notice.setObjectName("PrescriptionAiStatusNotice")
|
||||||
|
self.ai_status_notice.setProperty("role", "muted")
|
||||||
|
self.ai_status_notice.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.ai_status_notice.setWordWrap(True)
|
||||||
|
self.ai_status_notice.setMargin(12)
|
||||||
|
self.ai_status_notice.setAccessibleName("AI 分析状态")
|
||||||
|
layout.addWidget(self.ai_status_notice)
|
||||||
self.stack = QStackedWidget()
|
self.stack = QStackedWidget()
|
||||||
table_host = QWidget()
|
table_host = QWidget()
|
||||||
table_layout = QVBoxLayout(table_host)
|
table_layout = QVBoxLayout(table_host)
|
||||||
@@ -1201,6 +1252,8 @@ class PrescriptionsPage(QWidget):
|
|||||||
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
|
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
|
||||||
TableColumn("assistant_name", "医助", 125),
|
TableColumn("assistant_name", "医助", 125),
|
||||||
TableColumn("create_time", "创建时间", 180, _create_time_cell),
|
TableColumn("create_time", "创建时间", 180, _create_time_cell),
|
||||||
|
TableColumn("__ai_status__", "AI 分析", 144, lambda _value, _row: "—"),
|
||||||
|
TableColumn("__ai_agreement__", "与 AI 一致度", 130, lambda _value, _row: "—"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.setObjectName("PrescriptionTable")
|
self.table.setObjectName("PrescriptionTable")
|
||||||
@@ -1215,6 +1268,11 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.table.horizontalHeader().moveSection(2, 10)
|
self.table.horizontalHeader().moveSection(2, 10)
|
||||||
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
|
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
|
||||||
self.table.setColumnWidth(column, width)
|
self.table.setColumnWidth(column, width)
|
||||||
|
self.table.setColumnHidden(11, True)
|
||||||
|
self.table.setColumnHidden(12, True)
|
||||||
|
self.table.horizontalHeaderItem(12).setToolTip("分别显示千问、OpenAI 的药味与剂量一致度;点击查看逐味贡献。")
|
||||||
|
self.table.horizontalHeader().viewport().installEventFilter(self)
|
||||||
|
self.table.verticalScrollBar().valueChanged.connect(lambda _value: self._schedule_ai_statuses())
|
||||||
self.table.setWordWrap(False)
|
self.table.setWordWrap(False)
|
||||||
# Rows are not uniform: the number column's delegate grows a row that
|
# Rows are not uniform: the number column's delegate grows a row that
|
||||||
# carries an order warning so the warning text stays readable without a
|
# carries an order warning so the warning text stays readable without a
|
||||||
@@ -1228,6 +1286,9 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||||
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
||||||
|
self.table.itemClicked.connect(lambda item: self._open_ai_report() if item.column() in {11, 12} else None)
|
||||||
|
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
self.table.customContextMenuRequested.connect(self._open_ai_context_menu)
|
||||||
table_layout.addWidget(self.table, 1)
|
table_layout.addWidget(self.table, 1)
|
||||||
self.pager = InfiniteList(self._page_size)
|
self.pager = InfiniteList(self._page_size)
|
||||||
self.pager.bind(self.table)
|
self.pager.bind(self.table)
|
||||||
@@ -1275,6 +1336,19 @@ class PrescriptionsPage(QWidget):
|
|||||||
if column == 1:
|
if column == 1:
|
||||||
self.table.resizeRowsToContents()
|
self.table.resizeRowsToContents()
|
||||||
|
|
||||||
|
def eventFilter(self, watched: Any, event: Any) -> bool:
|
||||||
|
if hasattr(self, "table") and watched is self.table.horizontalHeader().viewport() and event.type() in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease, QEvent.Type.MouseButtonDblClick}:
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
position = event.position().toPoint()
|
||||||
|
column = header.logicalIndexAt(position)
|
||||||
|
if column in {11, 12} and event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
relative = position.x() - header.sectionViewportPosition(column)
|
||||||
|
# Keep native column resizing, but never rank mixed-model display
|
||||||
|
# text or treat unavailable values as numeric zero through sorting.
|
||||||
|
if 5 < relative < header.sectionSize(column) - 5:
|
||||||
|
return True
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
def _action_button(
|
def _action_button(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -1355,6 +1429,10 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._search()
|
self._search()
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
self._ai_request_token += 1
|
||||||
|
self._ai_pending = False
|
||||||
|
self._ai_statuses.clear()
|
||||||
self._loading = True
|
self._loading = True
|
||||||
self._refresh_pending = False
|
self._refresh_pending = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
@@ -1394,6 +1472,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
rows = page_items(result)
|
rows = page_items(result)
|
||||||
self.table.set_rows(rows)
|
self.table.set_rows(rows)
|
||||||
self._decorate_rows()
|
self._decorate_rows()
|
||||||
|
self._render_ai_statuses()
|
||||||
total = page_total(result, len(rows))
|
total = page_total(result, len(rows))
|
||||||
self.pager.update_state(requested_page, total)
|
self.pager.update_state(requested_page, total)
|
||||||
self.count_badge.setText(f"共 {total} 条")
|
self.count_badge.setText(f"共 {total} 条")
|
||||||
@@ -1416,6 +1495,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
if rows and self.table.currentRow() < 0:
|
if rows and self.table.currentRow() < 0:
|
||||||
self.table.selectRow(0)
|
self.table.selectRow(0)
|
||||||
self._selection_changed()
|
self._selection_changed()
|
||||||
|
self._load_ai_statuses()
|
||||||
|
|
||||||
def _decorate_rows(self) -> None:
|
def _decorate_rows(self) -> None:
|
||||||
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
|
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
|
||||||
@@ -1500,6 +1580,14 @@ class PrescriptionsPage(QWidget):
|
|||||||
actions_host,
|
actions_host,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self._ai_enabled and can_open_issued_ai(self.permissions) and not _is_blank_prescription(row):
|
||||||
|
actions.addWidget(
|
||||||
|
_row_action_button(
|
||||||
|
"eye", "AI 报告",
|
||||||
|
lambda _checked=False, target=row: self._run_row_action(target, self._open_ai_report),
|
||||||
|
actions_host, label="AI",
|
||||||
|
)
|
||||||
|
)
|
||||||
if has_permission(self.permissions, "cf.prescription/edit"):
|
if has_permission(self.permissions, "cf.prescription/edit"):
|
||||||
actions.addWidget(
|
actions.addWidget(
|
||||||
_row_action_button(
|
_row_action_button(
|
||||||
@@ -1533,6 +1621,178 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._sync_row_mutation_actions()
|
self._sync_row_mutation_actions()
|
||||||
self.table.resizeRowsToContents()
|
self.table.resizeRowsToContents()
|
||||||
|
|
||||||
|
def _visible_ai_ids(self) -> list[int]:
|
||||||
|
if not self.isVisible() or not self.table.isVisible():
|
||||||
|
return []
|
||||||
|
ids = []
|
||||||
|
viewport = self.table.viewport().rect()
|
||||||
|
for index in range(self.table.rowCount()):
|
||||||
|
item = self.table.item(index, 0)
|
||||||
|
# Column 0 may be horizontally off-screen; use row geometry only.
|
||||||
|
top = self.table.rowViewportPosition(index)
|
||||||
|
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
|
||||||
|
continue
|
||||||
|
row = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
continue
|
||||||
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
|
if value > 0:
|
||||||
|
ids.append(value)
|
||||||
|
return list(dict.fromkeys(ids))[:100]
|
||||||
|
|
||||||
|
def _schedule_ai_statuses(self) -> None:
|
||||||
|
if self.isVisible():
|
||||||
|
self._ai_scroll_timer.start()
|
||||||
|
|
||||||
|
def _load_ai_statuses(self) -> None:
|
||||||
|
only_blank = self.table.rowCount() > 0 and all(
|
||||||
|
_is_blank_prescription(self.table.item(index, 0).data(Qt.ItemDataRole.UserRole))
|
||||||
|
for index in range(self.table.rowCount())
|
||||||
|
)
|
||||||
|
self.ai_status_notice.setVisible(not only_blank)
|
||||||
|
if only_blank:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
self._set_ai_columns(False)
|
||||||
|
return
|
||||||
|
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
||||||
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
||||||
|
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
||||||
|
return
|
||||||
|
if not callable(method):
|
||||||
|
self.ai_status_notice.setText("AI 分析:当前数据模式暂不支持此功能。")
|
||||||
|
return
|
||||||
|
if self._ai_pending:
|
||||||
|
return
|
||||||
|
ids = self._visible_ai_ids()
|
||||||
|
ids = [value for value in ids if value not in self._ai_statuses or batch_running(self._ai_statuses[value])]
|
||||||
|
if not ids:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
if self.table.rowCount() == 0:
|
||||||
|
self.ai_status_notice.setText("AI 分析:当前列表没有处方,暂无可查看的分析结果。")
|
||||||
|
return
|
||||||
|
self._ai_pending = True
|
||||||
|
self._ai_request_token += 1
|
||||||
|
token, generation = self._ai_request_token, self._generation
|
||||||
|
run_async(
|
||||||
|
lambda: method(ids),
|
||||||
|
on_success=lambda result: self._ai_statuses_ready(result, ids, token, generation),
|
||||||
|
on_error=lambda error: self._ai_statuses_error(error, token, generation),
|
||||||
|
on_finished=lambda: self._ai_statuses_finished(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ai_statuses_finished(self, token: int) -> None:
|
||||||
|
if token == self._ai_request_token:
|
||||||
|
self._ai_pending = False
|
||||||
|
|
||||||
|
def _set_ai_columns(self, enabled: bool) -> None:
|
||||||
|
changed = enabled != self._ai_enabled
|
||||||
|
self._ai_enabled = enabled
|
||||||
|
for column in (11, 12):
|
||||||
|
self.table.setColumnHidden(column, not enabled)
|
||||||
|
if enabled:
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
||||||
|
header.moveSection(header.visualIndex(12), header.visualIndex(11) + 1)
|
||||||
|
self.table.setColumnWidth(11, 144)
|
||||||
|
self.table.setColumnWidth(12, 130)
|
||||||
|
self.table.setColumnWidth(2, 178)
|
||||||
|
if changed:
|
||||||
|
self._decorate_rows()
|
||||||
|
|
||||||
|
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
||||||
|
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
||||||
|
return
|
||||||
|
data = _row_mapping(result)
|
||||||
|
self._set_ai_columns(data.get("enabled") is True)
|
||||||
|
if not self._ai_enabled:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
notice = "AI 分析未启用:暂不生成报告或一致度,请联系管理员启用。"
|
||||||
|
if can_open_issued_ai(self.permissions):
|
||||||
|
notice += "已有报告仍可从“AI 报告”查看。"
|
||||||
|
self.ai_status_notice.setText(notice)
|
||||||
|
self.ai_status_notice.setToolTip("")
|
||||||
|
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
||||||
|
return
|
||||||
|
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
|
||||||
|
self.ai_status_notice.setToolTip("")
|
||||||
|
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
|
||||||
|
for value in ids:
|
||||||
|
self._ai_statuses[value] = {}
|
||||||
|
for batch in data.get("items") or []:
|
||||||
|
value = _int(get_value(batch, "prescription_id"), 0)
|
||||||
|
if value in ids:
|
||||||
|
self._ai_statuses[value] = _row_mapping(batch)
|
||||||
|
self._render_ai_statuses()
|
||||||
|
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
||||||
|
self._ai_timer.start()
|
||||||
|
else:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
|
||||||
|
def _render_ai_statuses(self) -> None:
|
||||||
|
sorting = self.table.isSortingEnabled()
|
||||||
|
self.table.setSortingEnabled(False)
|
||||||
|
changed = False
|
||||||
|
try:
|
||||||
|
for index in range(self.table.rowCount()):
|
||||||
|
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
|
batch = self._ai_statuses.get(value)
|
||||||
|
blank = _is_blank_prescription(row)
|
||||||
|
if batch is None and not blank:
|
||||||
|
continue
|
||||||
|
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
||||||
|
agreement = "" if blank else agreement_text(batch)
|
||||||
|
for column, text in ((11, state), (12, agreement)):
|
||||||
|
item = self.table.item(index, column)
|
||||||
|
if item.text() != text:
|
||||||
|
item.setText(text)
|
||||||
|
changed = True
|
||||||
|
item.setToolTip("" if blank else status_tooltip(batch))
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||||
|
if changed:
|
||||||
|
self.table.resizeRowsToContents()
|
||||||
|
finally:
|
||||||
|
self.table.setSortingEnabled(sorting)
|
||||||
|
|
||||||
|
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
||||||
|
if token == self._ai_request_token and generation == self._generation:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
self._set_ai_columns(False)
|
||||||
|
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
||||||
|
self.ai_status_notice.setToolTip(friendly_error(error))
|
||||||
|
self.ai_report_button.setToolTip("AI 状态暂不可用:" + friendly_error(error))
|
||||||
|
|
||||||
|
def _open_ai_report(self) -> None:
|
||||||
|
row = self._selected()
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
return
|
||||||
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
|
if value > 0:
|
||||||
|
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
|
||||||
|
|
||||||
|
def _open_ai_context_menu(self, position: Any) -> None:
|
||||||
|
if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
|
||||||
|
return
|
||||||
|
item = self.table.itemAt(position)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
self.table.selectRow(item.row())
|
||||||
|
row = self._selected()
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
return
|
||||||
|
menu = QMenu(self.table)
|
||||||
|
action = menu.addAction("AI 报告 / 逐味对照")
|
||||||
|
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||||
|
self._ai_context_menu = menu
|
||||||
|
menu.popup(self.table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def _open_ai_statistics(self) -> None:
|
||||||
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||||
|
return
|
||||||
|
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
||||||
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
||||||
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
||||||
if target_id <= 0:
|
if target_id <= 0:
|
||||||
@@ -1567,6 +1827,13 @@ class PrescriptionsPage(QWidget):
|
|||||||
row = self.table.current_data()
|
row = self.table.current_data()
|
||||||
active = not self._mutation_pending
|
active = not self._mutation_pending
|
||||||
self.view_button.setEnabled(active and row is not None)
|
self.view_button.setEnabled(active and row is not None)
|
||||||
|
ai_available = (
|
||||||
|
not _is_blank_prescription(row)
|
||||||
|
and can_open_issued_ai(self.permissions)
|
||||||
|
and callable(getattr(self.repository, "list_prescription_ai_reports", None))
|
||||||
|
)
|
||||||
|
self.ai_report_button.setVisible(ai_available)
|
||||||
|
self.ai_report_button.setEnabled(ai_available)
|
||||||
self.patch_button.setEnabled(active and can_patch_patient(row))
|
self.patch_button.setEnabled(active and can_patch_patient(row))
|
||||||
self.create_order_button.setEnabled(active and can_create_order(row))
|
self.create_order_button.setEnabled(active and can_create_order(row))
|
||||||
self.edit_button.setEnabled(active and can_edit_or_delete(row))
|
self.edit_button.setEnabled(active and can_edit_or_delete(row))
|
||||||
@@ -1939,6 +2206,15 @@ class PrescriptionsPage(QWidget):
|
|||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
if self.table.rowCount() == 0 and not self._loading:
|
if self.table.rowCount() == 0 and not self._loading:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
elif not self._loading:
|
||||||
|
self._load_ai_statuses()
|
||||||
|
|
||||||
|
def hideEvent(self, event: Any) -> None:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
self._ai_scroll_timer.stop()
|
||||||
|
self._ai_request_token += 1
|
||||||
|
self._ai_pending = False
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||||
from .. import icons
|
from .. import icons
|
||||||
from ..diagnosis_drawer import DailyRecordPanel
|
from ..diagnosis_drawer import DailyRecordPanel
|
||||||
from ..diagnosis_editors import FlowLayout
|
from ..diagnosis_editors import FlowLayout
|
||||||
@@ -7824,6 +7825,7 @@ class ReceptionPage(QWidget):
|
|||||||
meta_parts = [f"{gender} · {display_text(age)}岁", phone]
|
meta_parts = [f"{gender} · {display_text(age)}岁", phone]
|
||||||
if visit_id not in (None, ""):
|
if visit_id not in (None, ""):
|
||||||
meta_parts.append(f"就诊号:{display_text(visit_id)}")
|
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.setText(" | ".join(meta_parts))
|
||||||
self.patient_meta_label.setToolTip(" · ".join(condition_parts))
|
self.patient_meta_label.setToolTip(" · ".join(condition_parts))
|
||||||
status_number = (
|
status_number = (
|
||||||
@@ -7866,15 +7868,7 @@ class ReceptionPage(QWidget):
|
|||||||
display_text(first_value(appointment, "assistant_name"))
|
display_text(first_value(appointment, "assistant_name"))
|
||||||
)
|
)
|
||||||
self.appointment_labels["type"].setText(
|
self.appointment_labels["type"].setText(
|
||||||
display_text(
|
appointment_type_description(appointment_type_value(appointment))
|
||||||
first_value(
|
|
||||||
appointment,
|
|
||||||
"appointment_type_text",
|
|
||||||
"type_text",
|
|
||||||
"appointment_type",
|
|
||||||
"type",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.appointment_labels["channel"].setText(
|
self.appointment_labels["channel"].setText(
|
||||||
display_text(
|
display_text(
|
||||||
@@ -8928,6 +8922,12 @@ class ReceptionPage(QWidget):
|
|||||||
self.video_button.setEnabled(
|
self.video_button.setEnabled(
|
||||||
appointment_id is not None and diagnosis_id is not None and patient_id is not None
|
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)
|
self.edit_button.setEnabled(diagnosis_id is not None)
|
||||||
report_enabled = (
|
report_enabled = (
|
||||||
self._can_ai_report
|
self._can_ai_report
|
||||||
@@ -9684,6 +9684,7 @@ class ReceptionPage(QWidget):
|
|||||||
payload = {
|
payload = {
|
||||||
"source": "reception",
|
"source": "reception",
|
||||||
"appointment_id": appointment_id,
|
"appointment_id": appointment_id,
|
||||||
|
"appointment_type": appointment_type_value(appointment),
|
||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"patient_name": first_value(
|
"patient_name": first_value(
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ def format_record_time(value: Any, default: str = "—") -> str:
|
|||||||
raw = str(value).strip()
|
raw = str(value).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return default
|
return default
|
||||||
|
# The API sends 0 for "not set yet" (a snapshot cutoff that has not been frozen, an
|
||||||
|
# unfinished task); rendering it as a bare 0 or as 1970 would read as a real time.
|
||||||
|
try:
|
||||||
|
if float(raw) <= 0:
|
||||||
|
return default
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
|
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
|
||||||
stamp = float(raw)
|
stamp = float(raw)
|
||||||
if stamp >= 10_000_000_000:
|
if stamp >= 10_000_000_000:
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ def _normalized_key(key: Any) -> str:
|
|||||||
return "".join(character for character in str(key).lower() if character.isalnum())
|
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"}
|
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +150,8 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
|
|||||||
if hasattr(ticket, attribute_name)
|
if hasattr(ticket, attribute_name)
|
||||||
}
|
}
|
||||||
if adapted:
|
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")
|
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
|
||||||
|
|
||||||
|
|
||||||
@@ -199,8 +212,15 @@ class VideoCallRequest:
|
|||||||
patient_id: Identifier | None = None
|
patient_id: Identifier | None = None
|
||||||
call_record_id: Identifier | None = None
|
call_record_id: Identifier | None = None
|
||||||
backend_mode: BackendMode = BackendMode.EMBEDDED
|
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:
|
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, "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_id", _non_empty_string(self.user_id, "userID"))
|
||||||
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
|
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
|
||||||
@@ -257,6 +277,13 @@ class VideoCallRequest:
|
|||||||
"userSig": self.user_sig,
|
"userSig": self.user_sig,
|
||||||
"targetUserId": self.target_user_id,
|
"targetUserId": self.target_user_id,
|
||||||
"diagnosisId": self.diagnosis_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]:
|
def safe_log_context(self) -> dict[str, Any]:
|
||||||
@@ -340,6 +367,12 @@ def normalize_backend_ticket(
|
|||||||
diagnosis_id=normalized_diagnosis,
|
diagnosis_id=normalized_diagnosis,
|
||||||
patient_id=normalized_patient,
|
patient_id=normalized_patient,
|
||||||
call_record_id=payload_call_record,
|
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),
|
backend_mode=BackendMode.parse(backend_mode),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from .launcher import VideoCallRequest
|
from .launcher import VideoCallRequest, normalize_backend_ticket
|
||||||
|
|
||||||
ResultT = TypeVar("ResultT")
|
ResultT = TypeVar("ResultT")
|
||||||
|
|
||||||
@@ -288,7 +288,29 @@ class OrderedCallLifecycle:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self.bound_room_id or self._claimed_room_id
|
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:
|
with self._lock:
|
||||||
if self._start_future is not None:
|
if self._start_future is not None:
|
||||||
return self._start_future
|
return self._start_future
|
||||||
@@ -297,7 +319,8 @@ class OrderedCallLifecycle:
|
|||||||
raise ValueError("video repository does not implement start_call")
|
raise ValueError("video repository does not implement start_call")
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"diagnosis_id": self.request.diagnosis_id,
|
"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:
|
if self.request.patient_id is not None:
|
||||||
payload["patient_id"] = self.request.patient_id
|
payload["patient_id"] = self.request.patient_id
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
call_ended = Signal(str) # type: ignore[misc]
|
call_ended = Signal(str) # type: ignore[misc]
|
||||||
call_error = Signal(str) # type: ignore[misc]
|
call_error = Signal(str) # type: ignore[misc]
|
||||||
_start_completed = Signal(bool) # 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]
|
_room_completed = Signal(str, bool, str) # type: ignore[misc]
|
||||||
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
||||||
_transcription_completed = Signal(str, str, str, 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._connect_permissions()
|
||||||
|
|
||||||
self._start_completed.connect(self._on_lifecycle_started)
|
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._room_completed.connect(self._on_room_completed)
|
||||||
self._screenshot_completed.connect(self._on_screenshot_completed)
|
self._screenshot_completed.connect(self._on_screenshot_completed)
|
||||||
self._transcription_completed.connect(self._on_transcription_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)
|
QTimer.singleShot(0, self._open_diagnosis_safely)
|
||||||
return
|
return
|
||||||
if event == "call-start-request":
|
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
|
return
|
||||||
if event == "screenshot":
|
if event == "screenshot":
|
||||||
self._save_screenshot(str(message.get("dataUrl") or ""))
|
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:
|
def _prepare_call_cycle(self) -> None:
|
||||||
if self._closing or self._start_requested:
|
|
||||||
return
|
|
||||||
if self._call_cycle_closed:
|
if self._call_cycle_closed:
|
||||||
self.lifecycle = self._lifecycle_factory()
|
self.lifecycle = self._lifecycle_factory()
|
||||||
self._lifecycles.append(self.lifecycle)
|
self._lifecycles.append(self.lifecycle)
|
||||||
self._call_cycle_closed = False
|
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
|
self._start_requested = True
|
||||||
try:
|
try:
|
||||||
future = self.lifecycle.start()
|
future = self.lifecycle.start(call_type=call_type)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._start_requested = False
|
self._start_requested = False
|
||||||
self._on_lifecycle_started(False)
|
self._on_lifecycle_started(False)
|
||||||
@@ -1149,7 +1186,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._closing = True
|
self._closing = True
|
||||||
self._media_active = False
|
self._media_active = False
|
||||||
self._shutdown_timer.stop()
|
self._shutdown_timer.stop()
|
||||||
if self._start_requested and not self._call_cycle_closed:
|
# Also retire policy-only workers for IM sessions without a live call.
|
||||||
self.lifecycle.end(self._close_reason)
|
self.lifecycle.end(self._close_reason)
|
||||||
self._abort_local_audio_recording("")
|
self._abort_local_audio_recording("")
|
||||||
self._release_webengine()
|
self._release_webengine()
|
||||||
|
|||||||
@@ -278,8 +278,10 @@ def test_field_order_density_and_conditional_channel_row(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||||
def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
|
mode: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
repository = _VisualRepository(today_conflict=True)
|
repository = _VisualRepository(today_conflict=True)
|
||||||
host, drawer = _show_drawer(
|
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.status_label.isVisible()
|
||||||
assert unavailable.accessibleName() == "10:00-10:30 已约"
|
assert unavailable.accessibleName() == "10:00-10:30 已约"
|
||||||
available.click()
|
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"))
|
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||||
assert drawer.ok_button.isEnabled()
|
assert drawer.ok_button.isEnabled()
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
|||||||
"appointment_date": tomorrow,
|
"appointment_date": tomorrow,
|
||||||
"appointment_time": "09:30-10:00",
|
"appointment_time": "09:30-10:00",
|
||||||
"period": "all",
|
"period": "all",
|
||||||
"appointment_type": "video",
|
"appointment_type": mode,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"channel_source": "online",
|
"channel_source": "online",
|
||||||
"channel_source_detail": "",
|
"channel_source_detail": "",
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ def test_im_entry_does_not_require_a_live_video_hint(
|
|||||||
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
|
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
|
||||||
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
|
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
|
||||||
assert waiting.isEnabled()
|
assert waiting.isEnabled()
|
||||||
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
|
assert waiting.toolTip() == "打开患者 IM,可发送消息;通话能力由本次挂号决定"
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
|
|||||||
row = _row()
|
row = _row()
|
||||||
before = copy.deepcopy(row)
|
before = copy.deepcopy(row)
|
||||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
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]) == (
|
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||||
"—", "2026-08-06 时间 —",
|
"—", "2026-08-06 时间 — · 视频问诊",
|
||||||
)
|
)
|
||||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
"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, {}) == ("—", "时间 —")
|
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||||
for missing_id in (0, "", None):
|
for missing_id in (0, "", None):
|
||||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
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])
|
host.set_rows([row])
|
||||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||||
assert blue.model.index(0, 4).data().splitlines() == [
|
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() == "—"
|
assert blue.model.index(0, 9).data() == "—"
|
||||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||||
blue.set_rows([{**row, "appointments": []}])
|
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()
|
legacy.close()
|
||||||
blue.close()
|
blue.close()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
|||||||
|
"""Offline rendering and data-integrity checks for the prescription workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent, QObject, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs.issued_prescription_ai_comparison import (
|
||||||
|
PrescriptionComparisonPanel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def saved_row(name: str = "黄芪", *, doctor: Any = 30, candidate: Any = 15, unit: str = "g", basis: str = "per_dose", formula: str = "主方", processing: str = "生品") -> dict[str, Any]:
|
||||||
|
identity = {"name": name, "processing": processing, "formula_type": formula, "administration_route": "口服", "group": "", "unit": unit, "dose_basis": basis}
|
||||||
|
return {**identity, "key": "a1b2c3d4" * 8, "doctor": {**identity, "dosage": doctor}, "candidate": {**identity, "dosage": candidate}, "doctor_dosage": doctor, "candidate_dosage": candidate, "match_type": "matched"}
|
||||||
|
|
||||||
|
|
||||||
|
def saved_batch(rows: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
rows = rows if rows is not None else [saved_row(), saved_row("白术", doctor=9, candidate=12)]
|
||||||
|
herbs = []
|
||||||
|
for row in rows:
|
||||||
|
if row.get("candidate"):
|
||||||
|
original = deepcopy(row["candidate"])
|
||||||
|
original.pop("source_rows", None)
|
||||||
|
row["candidate"].setdefault("source_rows", [len(herbs)])
|
||||||
|
herbs.append(original)
|
||||||
|
candidate = {"status": "available_for_review", "prescription_name": "补中益气汤加减", "herbs": herbs, "usage_instruction": "水煎温服", "times_per_day": 2, "usage_days": 7}
|
||||||
|
qwen = {"status": "succeeded", "candidate": candidate, "comparison": {"status": "comparable", "score": 68.5, "herb_score": 100, "rows": rows}}
|
||||||
|
openai = deepcopy(qwen)
|
||||||
|
openai["candidate"]["prescription_name"] = "益气健脾方"
|
||||||
|
return {"id": 44, "validity": "current", "status": "completed", "models": {"qwen": qwen, "openai": openai}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def denied(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
pytest.fail("The comparison panel must never contact external systems")
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", denied)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", denied)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", denied)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def panel(application: QApplication):
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(1120, 400)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
widget = PrescriptionComparisonPanel(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_snapshot_is_readable_and_model_switch_is_explicit(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
before = deepcopy(data)
|
||||||
|
panel.set_batch(data)
|
||||||
|
application.processEvents()
|
||||||
|
assert data == before
|
||||||
|
assert panel.selected_model == "qwen"
|
||||||
|
assert panel.model_buttons["qwen"].isChecked()
|
||||||
|
assert panel.name_label.text() == "补中益气汤加减"
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert "主方" in panel.herb_table.item(0, 0).text()
|
||||||
|
assert "a1b2c3d4" not in panel.chart.accessibleDescription()
|
||||||
|
assert "药味剂量一致度" not in panel.chart.accessibleDescription()
|
||||||
|
assert "水煎温服" in panel.usage_label.text()
|
||||||
|
assert panel.chart.bars_enabled
|
||||||
|
assert panel.chart.groups[0][0] == ("克", "每剂")
|
||||||
|
panel.model_buttons["openai"].click()
|
||||||
|
assert panel.selected_model == "openai"
|
||||||
|
assert panel.name_label.text() == "益气健脾方"
|
||||||
|
assert panel.herb_table.horizontalHeaderItem(2).text() == "OpenAI剂量"
|
||||||
|
assert panel.chart.model_key == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("candidate_key", ["candidate_dose", "candidate_dosage", "ai_dose"])
|
||||||
|
def test_legacy_flat_doses_remain_supported(panel: PrescriptionComparisonPanel, candidate_key: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = [{"name": "黄芪", "doctor_dose": 0, candidate_key: "12.50", "unit": "g", "dose_basis": "每剂", "formula_type": "主方"}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "0 克 / 每剂"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "12.50 克 / 每剂"
|
||||||
|
assert panel.chart.rows[0].doctor.number == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_historical_doctor_is_never_replaced_with_current_prescription(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = []
|
||||||
|
data["prescription"] = {"herbs": [{"name": "黄芪", "dosage": 99, "unit": "g", "dose_basis": "per_dose"}]}
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert "历史处方" in panel.status_label.text()
|
||||||
|
assert "99" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rows", [None, {}, "unavailable"])
|
||||||
|
def test_malformed_comparison_keeps_candidate_list_without_claiming_a_snapshot(panel: PrescriptionComparisonPanel, rows: Any) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = rows
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_missing_snapshot_and_nested_unknown_unit_override_flat_values(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
row = saved_row()
|
||||||
|
row["doctor"] = None
|
||||||
|
row["candidate"]["unit"] = None
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert "单位未注明" in panel.herb_table.item(0, 2).text()
|
||||||
|
assert panel.chart.groups[0][0] is None
|
||||||
|
assert "未明确" in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomparable_report_retains_original_doses_without_bars(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"].update(status="not_comparable", score=99, reason="dose_basis_mismatch")
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert "剂量基准不同" in panel.status_label.text()
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂"
|
||||||
|
assert all(scale is None for scale, _rows, _maximum in panel.chart.groups)
|
||||||
|
assert "99" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_and_bases_have_independent_scales_and_mismatched_rows_are_excluded(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
rows = [saved_row("黄芪"), saved_row("白术", unit="mg", doctor=1000), saved_row("茯苓", basis="per_day", doctor=60), saved_row("甘草")]
|
||||||
|
rows[-1]["candidate"]["dose_basis"] = "per_day"
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
assert [scale for scale, _members, _maximum in panel.chart.groups] == [("克", "每剂"), ("毫克", "每剂"), ("克", "每日"), None]
|
||||||
|
assert panel.chart.groups[0][2] == 30
|
||||||
|
assert panel.chart.groups[1][2] == 1000
|
||||||
|
assert "单位或剂量基准不同" in panel.chart.accessibleDescription()
|
||||||
|
assert "每日" in panel.herb_table.item(3, 2).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_auxiliary_and_processing_rows_never_merge(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
rows = [saved_row("甘草", formula="主方"), saved_row("甘草", formula="辅方"), saved_row("甘草", processing="炙品")]
|
||||||
|
rows[2]["candidate"]["processing"] = "生品"
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
assert panel.herb_table.rowCount() == 3
|
||||||
|
assert "主方" in panel.herb_table.item(0, 0).text()
|
||||||
|
assert "辅方" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.chart.rows[2].scale is None
|
||||||
|
assert "炮制" in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("skipped_index", [0, 1])
|
||||||
|
def test_zero_based_trace_preserves_candidate_herbs_skipped_during_normalization(panel: PrescriptionComparisonPanel, skipped_index: int) -> None:
|
||||||
|
normalized = saved_row("黄芪")
|
||||||
|
normalized["candidate"]["source_rows"] = [1 - skipped_index]
|
||||||
|
data = saved_batch([normalized])
|
||||||
|
original = deepcopy(data["models"]["qwen"]["candidate"]["herbs"][0])
|
||||||
|
excluded = {**original, "name": "黄芪", "processing": "炮制待核对", "dosage": 8, "instructions": "先煎 30 分钟"}
|
||||||
|
herbs = [original]
|
||||||
|
herbs.insert(skipped_index, excluded)
|
||||||
|
data["models"]["qwen"]["candidate"]["herbs"] = herbs
|
||||||
|
# Even a contradictory comparable status must not grant the omitted raw herb a bar.
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert "未纳入对比" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "炮制待核对" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.herb_table.item(1, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(1, 2).text() == "8 克 / 每剂"
|
||||||
|
assert panel.chart.rows[1].scale is None
|
||||||
|
assert "先煎 30 分钟" in panel.chart.accessibleDescription()
|
||||||
|
assert "原方 2 项" in panel.count_label.text() and "未纳入 1 项" in panel.count_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merged_source_rows_cover_each_original_once_and_keep_original_doses(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
merged = saved_row("黄芪", candidate=10)
|
||||||
|
merged["candidate"]["source_rows"] = [0, 1]
|
||||||
|
data = saved_batch([merged])
|
||||||
|
original = data["models"]["qwen"]["candidate"]["herbs"][0]
|
||||||
|
data["models"]["qwen"]["candidate"]["herbs"] = [{**original, "name": "北芪", "dosage": 4}, {**original, "dosage": 6}, {**original, "name": "未识别药材", "dosage": 5}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "10 克 / 每剂"
|
||||||
|
tooltip = panel.herb_table.item(0, 0).toolTip()
|
||||||
|
assert "第 1 项 北芪 4 克 / 每剂" in tooltip
|
||||||
|
assert "第 2 项 黄芪 6 克 / 每剂" in tooltip
|
||||||
|
assert "未识别药材" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "未纳入对比" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "原方 3 项" in panel.count_label.text() and "对比 1 项" in panel.count_label.text()
|
||||||
|
panel.search.setText("北芪")
|
||||||
|
assert panel.herb_table.rowCount() == 1
|
||||||
|
assert "黄芪" in panel.herb_table.item(0, 0).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_missing_trace_retains_full_original_separately_without_name_matching(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch([saved_row("黄芪")])
|
||||||
|
model = data["models"]["qwen"]
|
||||||
|
del model["comparison"]["rows"][0]["candidate"]["source_rows"]
|
||||||
|
original = model["candidate"]["herbs"][0]
|
||||||
|
model["candidate"]["herbs"] = [{**original, "dosage": 4}, {**original, "name": "炮制待核对药材", "dosage": 6}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 3
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
for index, dose in ((1, "4 克 / 每剂"), (2, "6 克 / 每剂")):
|
||||||
|
assert "原方附列" in panel.herb_table.item(index, 0).text()
|
||||||
|
assert panel.herb_table.item(index, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(index, 2).text() == dose
|
||||||
|
assert panel.chart.rows[index].scale is None
|
||||||
|
assert "对应关系未保存" in panel.status_label.text()
|
||||||
|
assert "原方附列 2 项" in panel.count_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("trace", [[True], [1], [-1], ["0"], []])
|
||||||
|
def test_invalid_source_trace_never_hides_an_original_herb(panel: PrescriptionComparisonPanel, trace: list[Any]) -> None:
|
||||||
|
data = saved_batch([saved_row("黄芪")])
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"][0]["candidate"]["source_rows"] = trace
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert "原方附列" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.chart.rows[1].scale is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_normalized_usage_keeps_all_special_instructions_visible_and_accessible(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
row = saved_row("石膏")
|
||||||
|
row["doctor"]["usage"] = {"decoction_instruction": "先煎 30 分钟", "special_usage": "布包煎", "usage_time": "饭后", "usage_way": "温服"}
|
||||||
|
row["candidate"]["instructions"] = "后下 5 分钟"
|
||||||
|
row["candidate"]["usage"] = {"instructions": "后下 5 分钟", "decoction_instruction": "另煎", "special_usage": "分次兑服", "usage_instruction": "服前摇匀", "usage_time": "睡前", "usage_way": "温服"}
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
name_item = panel.herb_table.item(0, 0)
|
||||||
|
for instruction in ("先煎 30 分钟", "布包煎", "饭后", "温服", "后下 5 分钟", "另煎", "分次兑服", "服前摇匀", "睡前"):
|
||||||
|
assert instruction in name_item.toolTip()
|
||||||
|
assert instruction in name_item.data(Qt.ItemDataRole.AccessibleDescriptionRole)
|
||||||
|
assert instruction in panel.chart.accessibleDescription()
|
||||||
|
assert "先煎 30 分钟" in name_item.text() and "后下 5 分钟" in name_item.text()
|
||||||
|
assert panel.chart.rows[0].candidate.instructions.count("后下 5 分钟") == 1
|
||||||
|
assert panel.herb_table.rowHeight(0) >= panel.herb_table.fontMetrics().height() * 3 + 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_clicking_a_table_row_scrolls_to_its_chart_group(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
rows = [saved_row(f"药材{index}", unit="g" if index % 2 == 0 else "mg") for index in range(20)]
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
application.processEvents()
|
||||||
|
item = panel.herb_table.item(11, 0)
|
||||||
|
panel.herb_table.scrollToItem(item)
|
||||||
|
application.processEvents()
|
||||||
|
panel.chart_scroll.verticalScrollBar().setValue(0)
|
||||||
|
QTest.mouseClick(panel.herb_table.viewport(), Qt.MouseButton.LeftButton, pos=panel.herb_table.visualItemRect(item).center())
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.herb_table.currentRow() == 11
|
||||||
|
# Ten gram rows precede the milligram group; the clicked row is its sixth member.
|
||||||
|
expected_y = 8 + panel.chart.GROUP_HEIGHT * 2 + panel.chart.ROW_HEIGHT * 15
|
||||||
|
assert panel.chart_scroll.verticalScrollBar().value() == expected_y
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["processing", "formula_type", "administration_route", "group"])
|
||||||
|
def test_unrecognized_identity_values_do_not_become_equal_after_localization(panel: PrescriptionComparisonPanel, field: str) -> None:
|
||||||
|
row = saved_row()
|
||||||
|
row["doctor"][field] = "unknown_first"
|
||||||
|
row["candidate"][field] = "unknown_second"
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
assert panel.chart.rows[0].scale is None
|
||||||
|
assert "unknown_first" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, "适量", -3, float("nan"), float("inf"), True])
|
||||||
|
def test_invalid_or_missing_doses_never_produce_numeric_bars(panel: PrescriptionComparisonPanel, application: QApplication, value: Any) -> None:
|
||||||
|
panel.set_batch(saved_batch([saved_row(doctor=value)]))
|
||||||
|
assert panel.chart.rows[0].doctor.number is None
|
||||||
|
assert panel.chart.groups[0][2] == 15
|
||||||
|
application.processEvents()
|
||||||
|
assert not panel.chart.grab().isNull()
|
||||||
|
if value is None or value is True:
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("status", "expected"), [("running", "正在生成"), ("failed", "未完成"), ("future_state", "状态未确认")])
|
||||||
|
def test_processing_failed_and_unknown_model_states_are_explicit(panel: PrescriptionComparisonPanel, status: str, expected: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"].update(status=status, candidate=None, comparison=None)
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert expected in panel.status_label.text()
|
||||||
|
assert panel.empty_label.isVisible()
|
||||||
|
assert panel.herb_table.rowCount() == 0
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("validity", ["stale", "source_updated", "future_validity"])
|
||||||
|
def test_outdated_and_unknown_validity_only_show_saved_original_values(panel: PrescriptionComparisonPanel, validity: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["validity"] = validity
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert "历史原值" in panel.status_label.text()
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_preserves_search_model_selection_and_both_scroll_positions(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
data = saved_batch([saved_row(f"黄芪{index}") for index in range(40)])
|
||||||
|
panel.set_batch(data)
|
||||||
|
panel.model_buttons["openai"].click()
|
||||||
|
panel.search.setText("黄芪")
|
||||||
|
panel.herb_table.selectRow(10)
|
||||||
|
application.processEvents()
|
||||||
|
panel.herb_table.verticalScrollBar().setValue(180)
|
||||||
|
panel.chart_scroll.verticalScrollBar().setValue(250)
|
||||||
|
before = (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
for _ in range(3):
|
||||||
|
panel.set_batch(deepcopy(data))
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.search.text() == "黄芪"
|
||||||
|
assert panel.selected_model == "openai"
|
||||||
|
assert panel.herb_table.currentRow() == 10
|
||||||
|
assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
changed = deepcopy(data)
|
||||||
|
changed["models"]["qwen"]["progress"] = {"stage": "completed"}
|
||||||
|
panel.set_batch(changed)
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.search.text() == "黄芪" and panel.selected_model == "openai"
|
||||||
|
assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
panel.search.setText("黄芪39")
|
||||||
|
assert panel.herb_table.rowCount() == 1
|
||||||
|
assert len(panel.chart.rows) == 1
|
||||||
|
panel.search.setText("未匹配")
|
||||||
|
assert "没有匹配" in panel.empty_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parent_owned_controls_do_not_flash_windows_on_update(application: QApplication) -> None:
|
||||||
|
shown_windows = []
|
||||||
|
|
||||||
|
class WindowObserver(QObject):
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||||
|
if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow():
|
||||||
|
shown_windows.append(watched)
|
||||||
|
return False
|
||||||
|
|
||||||
|
observer = WindowObserver()
|
||||||
|
application.installEventFilter(observer)
|
||||||
|
host = QWidget()
|
||||||
|
try:
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
widget = PrescriptionComparisonPanel(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
widget.set_batch(saved_batch())
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert shown_windows == [host]
|
||||||
|
shown_windows.clear()
|
||||||
|
widget.set_batch(saved_batch([saved_row("当归")]))
|
||||||
|
widget.model_buttons["openai"].click()
|
||||||
|
widget.set_batch({})
|
||||||
|
application.processEvents()
|
||||||
|
assert shown_windows == []
|
||||||
|
assert all(child.parentWidget() is not None for child in widget.findChildren(QWidget))
|
||||||
|
assert widget.herb_table.rowCount() == 0
|
||||||
|
assert widget.chart.rows == []
|
||||||
|
finally:
|
||||||
|
application.removeEventFilter(observer)
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("size", [(1120, 400), (940, 340)])
|
||||||
|
def test_compact_sizes_keep_table_and_chart_scrollable(panel: PrescriptionComparisonPanel, application: QApplication, size: tuple[int, int]) -> None:
|
||||||
|
panel.parentWidget().resize(*size)
|
||||||
|
panel.set_batch(saved_batch([saved_row(f"药材{index}") for index in range(60)]))
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.width() <= size[0] and panel.height() <= size[1]
|
||||||
|
assert panel.herb_table.viewport().height() >= 65
|
||||||
|
assert panel.herb_table.viewport().width() >= 320
|
||||||
|
assert panel.chart_scroll.viewport().height() >= 90
|
||||||
|
assert panel.herb_table.verticalScrollBar().maximum() > 0
|
||||||
|
assert panel.chart_scroll.verticalScrollBar().maximum() > 0
|
||||||
|
assert not panel.grab().isNull()
|
||||||
|
assert panel.herb_table.item(0, 2).data(Qt.ItemDataRole.AccessibleTextRole) == "15 克 / 每剂"
|
||||||
|
assert "药材59" in panel.chart.accessibleDescription()
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""The composed pages must show the saved batch exactly, including what is missing from it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai_pages as pages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_rows(model: str) -> list[dict[str, Any]]:
|
||||||
|
doses = {"qwen": {"生地黄": 15, "生麦冬": 12, "麸炒白术": 12}, "openai": {"生地黄": 12, "茯苓": 8}}[model]
|
||||||
|
contributions = {"qwen": {"生地黄": 0.94, "生麦冬": 1.0}, "openai": {"生地黄": 0.75}}[model]
|
||||||
|
doctor = {"生地黄": 16, "生麦冬": 12, "红参片": 6, "茯苓": 10}
|
||||||
|
rows = []
|
||||||
|
for name in sorted(set(doses) | set(doctor)):
|
||||||
|
rows.append({
|
||||||
|
"name": name, "unit": "g",
|
||||||
|
"doctor_dosage": doctor.get(name),
|
||||||
|
"candidate_dosage": doses.get(name),
|
||||||
|
"contribution": contributions.get(name),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def batch(**overrides: Any) -> dict[str, Any]:
|
||||||
|
value = {
|
||||||
|
"id": 13, "status": "success", "coverage_status": "partial", "comparison_type": "latest_context",
|
||||||
|
"source_summary": {"attachment_count": 4, "diagnoses_count": 1, "video_calls_count": 3,
|
||||||
|
"source_record_count": 9},
|
||||||
|
"missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}],
|
||||||
|
"models": {
|
||||||
|
"qwen": {"status": "success", "algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||||
|
"comparison": {"status": "comparable", "rows": comparison_rows("qwen")},
|
||||||
|
"coverage": {"files": [{"status": "processed"}, {"status": "processed"},
|
||||||
|
{"status": "processed"}, {"status": "restricted"}]},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 135, "attempt": 1},
|
||||||
|
"usage": {"total_calls": 2, "calls": [
|
||||||
|
{"stage": "text:0", "ok": True, "latency_ms": 3400, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 1020}, "error_code": ""},
|
||||||
|
{"stage": "final", "ok": False, "latency_ms": 14400, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 5617}, "error_code": "INVALID_REPORT_OUTPUT"}]}},
|
||||||
|
"openai": {"status": "success", "comparison": {"status": "comparable", "rows": comparison_rows("openai")},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 593, "attempt": 1},
|
||||||
|
"usage": {"calls": []}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
value.update(overrides)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- candidates page
|
||||||
|
@pytest.fixture
|
||||||
|
def per_herb(application: QApplication) -> pages.CandidatesPage:
|
||||||
|
widget = pages.CandidatesPage()
|
||||||
|
widget.resize(1200, 700)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _dose_cells(page: pages.CandidatesPage, row: int) -> tuple[str, str]:
|
||||||
|
return (page.table.cellWidget(row, 1).dose, page.table.cellWidget(row, 2).dose)
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(page: pages.CandidatesPage, row: int) -> str:
|
||||||
|
return page.table.cellWidget(row, 4).findChildren(QLabel)[0].text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_merges_both_models_into_one_row(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
assert names.count("生地黄") == 1
|
||||||
|
row = names.index("生地黄")
|
||||||
|
assert _dose_cells(per_herb, row) == ("15 g", "12 g")
|
||||||
|
assert per_herb.table.item(row, 3).text() == "16 g"
|
||||||
|
assert per_herb.table.cellWidget(row, 1).contribution == 0.94
|
||||||
|
assert per_herb.table.cellWidget(row, 2).contribution == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_marks_absence_without_inventing_a_dose(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
only_doctor = names.index("红参片")
|
||||||
|
assert _dose_cells(per_herb, only_doctor) == ("—", "—")
|
||||||
|
assert per_herb.table.cellWidget(only_doctor, 1).contribution is None
|
||||||
|
assert _verdict(per_herb, only_doctor) == "仅医方使用"
|
||||||
|
added = names.index("麸炒白术")
|
||||||
|
assert per_herb.table.item(added, 3).text() == "未收录"
|
||||||
|
assert _verdict(per_herb, added) == "仅 千问 收录"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_conclusion_states_a_dose_gap_over_the_threshold(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
row = names.index("生地黄")
|
||||||
|
assert _verdict(per_herb, row) == "两模型均收录" # 医方 16,两侧差 1 与 4,未过 5 克阈值
|
||||||
|
source = batch()
|
||||||
|
for model in source["models"].values():
|
||||||
|
for entry in model["comparison"]["rows"]:
|
||||||
|
if entry["name"] == "生地黄":
|
||||||
|
entry["candidate_dosage"] = 9
|
||||||
|
per_herb.set_batch(source)
|
||||||
|
names = [per_herb.table.item(index, 0).text() for index in range(per_herb.table.rowCount())]
|
||||||
|
assert _verdict(per_herb, names.index("生地黄")) == "剂量分歧 7 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_filters_count_and_narrow_the_table(
|
||||||
|
per_herb: pages.CandidatesPage, application: QApplication) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
total = per_herb.table.rowCount()
|
||||||
|
assert per_herb.filter_buttons["all"].text() == f"全部 {total}"
|
||||||
|
per_herb.search.setText("生地黄")
|
||||||
|
application.processEvents()
|
||||||
|
assert per_herb.table.rowCount() == 1
|
||||||
|
per_herb.search.clear()
|
||||||
|
per_herb.filter_buttons["doctor"].click()
|
||||||
|
application.processEvents()
|
||||||
|
assert 0 < per_herb.table.rowCount() < total
|
||||||
|
assert all(_verdict(per_herb, row) in {"仅医方使用", "两模型均未收录"}
|
||||||
|
for row in range(per_herb.table.rowCount()))
|
||||||
|
per_herb.filter_buttons["single"].click()
|
||||||
|
application.processEvents()
|
||||||
|
assert all("仅 " in _verdict(per_herb, row) for row in range(per_herb.table.rowCount()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_cards_show_each_saved_prescription(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
|
||||||
|
"herbs": [{"name": "生地黄", "dosage": 16, "unit": "g"}, {"name": "红参片", "dosage": 6, "unit": "g"}],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": "每日1剂,水煎分服。"}}))
|
||||||
|
doctor = per_herb.cards["doctor"]
|
||||||
|
assert doctor.summary.text() == "2 味 · 浓缩水丸"
|
||||||
|
assert "生地黄" in doctor.herbs.text() and "16 g" in doctor.herbs.text()
|
||||||
|
assert "每日1剂" in doctor.usage.text()
|
||||||
|
assert per_herb.cards["qwen"].summary.text() == "尚无候选方"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_card_grows_for_a_long_usage_note(per_herb: pages.CandidatesPage,
|
||||||
|
application: QApplication) -> None:
|
||||||
|
"""A card must not cap itself and cut the herb list or the 方义 in half."""
|
||||||
|
|
||||||
|
note = "方中生黄芪益气固表,生地黄、生麦冬滋阴清热。" * 8
|
||||||
|
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
|
||||||
|
"herbs": [{"name": f"药{index}", "dosage": 15, "unit": "g"} for index in range(20)],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": note}}))
|
||||||
|
per_herb.resize(1100, 700)
|
||||||
|
application.processEvents()
|
||||||
|
card = per_herb.cards["doctor"]
|
||||||
|
assert "另有 15 味" in card.herbs.text()
|
||||||
|
assert card.usage.height() >= card.usage.heightForWidth(card.usage.width())
|
||||||
|
assert card.height() >= card.herbs.height() + card.usage.height()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_says_when_no_prescription_was_saved(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch({})
|
||||||
|
assert per_herb.cards["doctor"].summary.text() == "未保存原方"
|
||||||
|
assert per_herb.cards["doctor"].herbs.text() == "尚未保存药味"
|
||||||
|
assert per_herb.table.rowCount() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sources page
|
||||||
|
@pytest.fixture
|
||||||
|
def sources(application: QApplication) -> pages.SourcesPage:
|
||||||
|
widget = pages.SourcesPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_groups_gaps_by_type_and_keeps_criticality(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
rows = {sources.gaps.item(row, 0).text(): (sources.gaps.item(row, 1).text(), sources.gaps.item(row, 2).text())
|
||||||
|
for row in range(sources.gaps.rowCount())}
|
||||||
|
transcript = next(key for key in rows if "转写" in key)
|
||||||
|
assert rows[transcript] == ("关键", "2")
|
||||||
|
archive = next(key for key in rows if "归档" in key)
|
||||||
|
assert rows[archive][0] == "一般"
|
||||||
|
|
||||||
|
|
||||||
|
def _composition(sources: pages.SourcesPage) -> dict[str, str]:
|
||||||
|
rows = {}
|
||||||
|
for index in range(sources.composition_layout.count()):
|
||||||
|
widget = sources.composition_layout.itemAt(index).widget()
|
||||||
|
labels = widget.findChildren(QLabel)
|
||||||
|
rows[labels[0].text()] = labels[-1].text()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_reports_attachment_reading_and_composition(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "模型实际读取 3 个" in sources.attachment_note.text()
|
||||||
|
assert sources.waffle.accessibleDescription() == "附件 4 个:已读 3,受限或不支持 1"
|
||||||
|
assert _composition(sources)["问诊通话"] == "3"
|
||||||
|
assert "soft-dice" in sources.meta.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_reports_what_each_model_managed_to_read(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
rows = {sources.per_model.item(row, 0).text(): (sources.per_model.item(row, 1).text(),
|
||||||
|
sources.per_model.item(row, 3).text())
|
||||||
|
for row in range(sources.per_model.rowCount())}
|
||||||
|
assert rows["千问"] == ("3", "75%")
|
||||||
|
assert "已读取 3" in sources.attachment_legend.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_meta_states_the_cutoff_and_reads_codes_in_chinese(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch(cutoff_at="2026-09-10 15:29"))
|
||||||
|
text = sources.meta.text()
|
||||||
|
assert "资料截止:2026-09-10 15:29" in text
|
||||||
|
assert "覆盖状态:部分资料缺失" in text # partial 在覆盖语境里说的是资料,不是进度
|
||||||
|
assert "对照类型:最新资料对照" in text
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "资料截止:—" in sources.meta.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_stays_empty_without_a_batch(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch({})
|
||||||
|
assert _composition(sources) == {}
|
||||||
|
assert sources.gaps.rowCount() == 0
|
||||||
|
assert sources.attachment_note.text() == "本次没有附件"
|
||||||
|
assert not sources.gap_note.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_puts_critical_gaps_first_and_counts_them(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "关键" in sources.gaps.item(0, 1).text()
|
||||||
|
assert "3 项 · 关键 2" in sources.gap_title.findChildren(QLabel)[-1].text()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- progress page
|
||||||
|
@pytest.fixture
|
||||||
|
def progress(application: QApplication) -> pages.ProgressPage:
|
||||||
|
widget = pages.ProgressPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_lists_every_call_with_its_outcome(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert progress.calls.rowCount() == 2
|
||||||
|
assert progress.calls.item(0, 0).text() == "千问"
|
||||||
|
assert progress.calls.item(0, 1).text() == "文字资料 1" # 阶段键不直接露出
|
||||||
|
assert progress.calls.item(1, 1).text() == "生成候选与报告"
|
||||||
|
assert progress.calls.item(0, 3).text() == "3.4 s"
|
||||||
|
assert progress.calls.item(0, 6).text() == "通过"
|
||||||
|
assert progress.calls.item(1, 5).text() == "5617"
|
||||||
|
# 最慢的一次调用占满耗时分布条,其余按比例
|
||||||
|
assert progress.calls.cellWidget(1, 2)._fraction == 1.0
|
||||||
|
assert progress.calls.cellWidget(0, 2)._fraction < 0.3
|
||||||
|
assert "INVALID_REPORT_OUTPUT" not in progress.calls.item(1, 5).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_shows_each_model_stage_and_attempt(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert "处理完成" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "第 1 次尝试" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "已用时 2 分 15 秒" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "已用时 9 分 53 秒" in progress.stage_labels["openai"].text()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- history page
|
||||||
|
@pytest.fixture
|
||||||
|
def history(application: QApplication) -> pages.HistoryPage:
|
||||||
|
widget = pages.HistoryPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_orders_by_time_and_keeps_uncomparable_slots_empty(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 13, "created_at": 300, "status": "success", "comparison_type": "latest_context",
|
||||||
|
"models": {"qwen": {"score": 18.9, "comparison_status": "comparable",
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0"},
|
||||||
|
"openai": {"score": 16.7, "comparison_status": "comparable"}}},
|
||||||
|
{"id": 11, "created_at": 100, "status": "failed",
|
||||||
|
"models": {"qwen": {"score": None, "comparison_status": "not_comparable"},
|
||||||
|
"openai": {"score": None, "comparison_status": "not_comparable"}}},
|
||||||
|
])
|
||||||
|
description = history.chart.accessibleDescription()
|
||||||
|
assert description.index("11") < description.index("13") # oldest first on the chart
|
||||||
|
assert "11 千问 — OpenAI —" in description
|
||||||
|
assert history.table.item(0, 0).text() == "#13" # newest first in the list
|
||||||
|
assert history.table.item(0, 3).text() == "18.9%"
|
||||||
|
assert history.table.item(1, 3).text() == "—"
|
||||||
|
assert history.table.item(0, 5).text() == "v1.1.0"
|
||||||
|
assert history.chart.has_data()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_reads_the_score_from_either_payload_shape(history: pages.HistoryPage) -> None:
|
||||||
|
"""列表接口把分数摊平,详情接口留在 comparison 里,两种都要认。"""
|
||||||
|
history.set_history([
|
||||||
|
{"id": 40, "created_at": "2026-09-10 15:29:00", "status": "success", "comparison_type": "non_independent",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 94.44}},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable", "score": 93.3}}}},
|
||||||
|
{"id": 39, "created_at": "2026-09-09 09:00:00", "status": "success",
|
||||||
|
"models": {"qwen": {"score": 42.9, "comparison_status": "comparable"}}},
|
||||||
|
])
|
||||||
|
assert history.table.item(0, 0).text() == "#40"
|
||||||
|
assert history.table.item(0, 3).text() == "94.4%"
|
||||||
|
assert history.table.item(0, 4).text() == "—" # 不可比就不给分,哪怕载荷里带着数字
|
||||||
|
assert "分层" in history.strata.text() or "同一套" in history.strata.text()
|
||||||
|
assert history.table.item(1, 3).text() == "42.9%"
|
||||||
|
description = history.chart.accessibleDescription()
|
||||||
|
assert description.index("39") < description.index("40")
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_without_any_comparable_batch_draws_nothing(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([{"id": 1, "created_at": 1, "models": {"qwen": {"comparison_status": "not_comparable"}}}])
|
||||||
|
assert not history.chart.has_data()
|
||||||
|
assert history.table.rowCount() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- statistics panel
|
||||||
|
@pytest.fixture
|
||||||
|
def statistics(application: QApplication) -> pages.StatisticsPanel:
|
||||||
|
widget = pages.StatisticsPanel()
|
||||||
|
widget.resize(1000, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def statistics_payload() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"total_count": 10, "patient_count": 8,
|
||||||
|
"doctors": [
|
||||||
|
{"doctor_id": 26, "doctor_name": "何医生", "total_count": 6, "patient_count": 5, "paired_count": 3,
|
||||||
|
"models": {"qwen": {"eligible_count": 4, "mean": 18.4, "median": 16.9,
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 2}},
|
||||||
|
"openai": {"eligible_count": 3, "mean": 21.0, "median": 19.6,
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3}}},
|
||||||
|
"review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}},
|
||||||
|
{"doctor_id": 31, "doctor_name": "李医生", "total_count": 4, "patient_count": 3, "paired_count": 1,
|
||||||
|
"models": {"qwen": {"eligible_count": 2, "mean": 25.0, "excluded_reasons": {"incomplete_coverage": 2}},
|
||||||
|
"openai": {"eligible_count": 0, "mean": None, "excluded_reasons": {"incomplete_coverage": 4}}},
|
||||||
|
"review": {"evaluated_count": 2, "qualified_count": 1, "qualified_rate": 50.0}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_headline_counts_and_coverage(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.kpi_values["events"].text() == "10"
|
||||||
|
assert "涉及患者 8 人" in statistics.kpi_notes["events"].text()
|
||||||
|
assert statistics.kpi_values["qwen"].text() == "6"
|
||||||
|
assert "覆盖率 60.0%" in statistics.kpi_notes["qwen"].text()
|
||||||
|
assert statistics.kpi_values["openai"].text() == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_review_rate_needs_a_review_sample(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
payload = statistics_payload()
|
||||||
|
for doctor in payload["doctors"]:
|
||||||
|
doctor["review"] = {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}
|
||||||
|
statistics.set_statistics(payload)
|
||||||
|
assert statistics.kpi_values["review"].text() == "—"
|
||||||
|
assert "尚未建立复核样本" in statistics.kpi_notes["review"].text()
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.kpi_values["review"].text() == "50.0%"
|
||||||
|
|
||||||
|
|
||||||
|
def _bars(layout) -> list[str]:
|
||||||
|
return [layout.itemAt(index).widget().accessibleDescription() for index in range(layout.count())]
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_funnel_and_exclusions_are_aggregated(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
funnel = _bars(statistics.funnel_layout)
|
||||||
|
assert "范围内开方事件 10" in funnel
|
||||||
|
assert "千问 有效基线比较 6" in funnel
|
||||||
|
assert "两模型配对共同样本 4" in funnel
|
||||||
|
reasons = _bars(statistics.exclusion_layout)
|
||||||
|
assert any(text.endswith(" 5") for text in reasons) # 来源历史版本无法重建 2 + 3
|
||||||
|
assert all("SOURCE_HISTORY" not in text for text in reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_distribution_sums_the_saved_bins(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
payload = statistics_payload()
|
||||||
|
payload["doctors"][0]["models"]["qwen"]["distribution"] = {"[0,20)": 3, "[20,40)": 1}
|
||||||
|
payload["doctors"][1]["models"]["qwen"]["distribution"] = {"[0,20)": 2}
|
||||||
|
statistics.set_statistics(payload)
|
||||||
|
assert statistics.distribution.has_data()
|
||||||
|
assert "千问 5/1/0/0/0" in statistics.distribution.accessibleDescription()
|
||||||
|
assert statistics.distribution.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_draws_no_distribution_without_samples(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert not statistics.distribution.has_data()
|
||||||
|
assert statistics.chart_empty.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_summary_row_pairs_mean_with_median(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.summary_values["qwen"].text() == "21.7% / 16.9%"
|
||||||
|
assert statistics.summary_values["paired"].text() == "4 例"
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_lists_each_doctor_without_ranking(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.doctors.rowCount() == 2
|
||||||
|
assert statistics.doctors.item(0, 0).text() == "何医生"
|
||||||
|
assert statistics.doctors.item(0, 3).text() == "4 / 18.4%"
|
||||||
|
assert statistics.doctors.item(1, 4).text() == "0 / —"
|
||||||
|
assert statistics.doctors.item(1, 6).text() == "50.0%"
|
||||||
|
assert "不是医生准确率" in statistics.footnote.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_counts_the_batch_in_the_stat_row(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert progress.stat_values["calls"].text() == "2 次"
|
||||||
|
assert progress.stat_values["failures"].text() == "1 次"
|
||||||
|
assert progress.stat_values["repairs"].text() == "0 次"
|
||||||
|
assert progress.stat_values["elapsed"].text() == "9 分 53 秒"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_derives_its_stages_from_the_saved_calls(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
names = []
|
||||||
|
layout = progress.stage_lists["qwen"]
|
||||||
|
for index in range(layout.count()):
|
||||||
|
widget = layout.itemAt(index).widget()
|
||||||
|
names.append(widget.findChildren(QLabel)[1].text())
|
||||||
|
assert names == ["文字资料分析", "生成候选与报告"]
|
||||||
|
assert progress.stage_lists["openai"].count() == 0 # 没有调用记录就不编造阶段
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_names_the_version_change_between_two_batches(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 5, "created_at": "2026-09-10 15:29:00", "status": "success",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 15.3},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "v4"}}},
|
||||||
|
{"id": 4, "created_at": "2026-09-10 15:18:00", "status": "success",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 18.9},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1",
|
||||||
|
"prompt_version": "v3"}}},
|
||||||
|
])
|
||||||
|
text = history.strata.text()
|
||||||
|
assert "比较算法 v1.0.1 → v1.1.0" in text
|
||||||
|
assert "提示词 v3 → v4" in text
|
||||||
|
assert "不能直接相减" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_says_when_every_batch_shares_one_version(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 2, "created_at": "2026-09-10 15:29:00",
|
||||||
|
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
|
||||||
|
{"id": 1, "created_at": "2026-09-10 14:29:00",
|
||||||
|
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
|
||||||
|
])
|
||||||
|
assert "全部批次使用同一套算法" in history.strata.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_shows_why_a_batch_has_no_score(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([{"id": 2, "created_at": "2026-09-10 13:25:00", "status": "failed",
|
||||||
|
"models": {"qwen": {"error_message": "模型返回未通过校验"}}}])
|
||||||
|
assert "模型返回未通过校验" in history.table.item(0, 2).text()
|
||||||
|
assert history.table.item(0, 3).text() == "—"
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""Data provenance and native UI checks for the prescription overview page."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs.issued_prescription_ai_workspace import (
|
||||||
|
PrescriptionReviewWorkspace,
|
||||||
|
checklist_items,
|
||||||
|
diagnosis_text,
|
||||||
|
dose_deltas,
|
||||||
|
gap_counts,
|
||||||
|
review_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batch(count: int = 3) -> dict:
|
||||||
|
rows = []
|
||||||
|
herbs = []
|
||||||
|
for index in range(count):
|
||||||
|
herb = {"name": f"药材{index}", "dosage": "15.00", "unit": "g", "dose_basis": "per_dose", "formula_type": "主方", "processing": "生品"}
|
||||||
|
rows.append({"key": f"saved-identity-{index}", "name": herb["name"], "doctor": {**herb, "dosage": "30.00"}, "candidate": {**herb, "source_rows": [index]}, "match_type": "matched"})
|
||||||
|
herbs.append(herb)
|
||||||
|
model = {"status": "succeeded", "report": {"diagnosis": "已保存的辨证意见", "summary": "不能当作诊断的摘要", "missing_information": ["缺少舌脉记录"]}, "candidate": {"status": "available_for_review", "herbs": herbs}, "comparison": {"status": "comparable", "rows": rows}}
|
||||||
|
return {"id": 4, "validity": "current", "models": {"qwen": deepcopy(model), "openai": deepcopy(model)}, "doctor_snapshot": {"patient": {"name": "测试患者", "gender": "male", "age": 50}, "diagnosis": {"western_diagnosis": "已记录西医诊断", "tcm_diagnosis": "已记录中医诊断", "syndrome": "已记录证候"}, "prescription": {"herbs": deepcopy(herbs), "usage_instruction": "水煎服", "usage_days": 7, "times_per_day": 2}}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace(application):
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(1024, 760)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
widget = PrescriptionReviewWorkspace(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_series_need_saved_unique_identity_and_same_baseline():
|
||||||
|
source = batch()
|
||||||
|
before = deepcopy(source)
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert source == before
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert all(set(row.doses) == {"doctor", "qwen", "openai"} for row in rows)
|
||||||
|
assert rows[0].scale == ("克", "每剂")
|
||||||
|
assert rows[0].changed
|
||||||
|
assert "15.00 克 / 每剂" in rows[0].description
|
||||||
|
assert "候选原方记录:第 1 项 药材0" in rows[0].description
|
||||||
|
assert "saved-identity" not in rows[0].description
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["no_keys", "different_baseline", "duplicate_keys", "different_units", "different_identity"])
|
||||||
|
def test_no_guessed_cross_model_join(mutation):
|
||||||
|
source = batch(1)
|
||||||
|
target = source["models"]["openai"]["comparison"]["rows"][0]
|
||||||
|
if mutation == "no_keys":
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0].pop("key")
|
||||||
|
elif mutation == "different_baseline":
|
||||||
|
target["doctor"]["dosage"] = 31
|
||||||
|
elif mutation == "duplicate_keys":
|
||||||
|
source["models"]["openai"]["comparison"]["rows"].append(deepcopy(target))
|
||||||
|
elif mutation == "different_units":
|
||||||
|
target["candidate"]["unit"] = "mg"
|
||||||
|
else:
|
||||||
|
target["candidate"]["processing"] = "炙品"
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert len(rows) >= 2
|
||||||
|
assert all(len(row.entries) == 1 for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["nan", "-3", "1/2", "Infinity", None])
|
||||||
|
def test_invalid_numbers_and_missing_are_never_zero(value):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = value
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows[0].scale is None
|
||||||
|
assert not rows[0].changed
|
||||||
|
if value is None:
|
||||||
|
assert rows[0].entries["qwen"].candidate.label == "—"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [("validity", "stale"), ("validity", "source_updated"), ("validity", None), ("status", "running"), ("status", "failed"), ("comparison", "not_comparable"), ("candidate", "withheld_for_risk")])
|
||||||
|
def test_pending_historical_and_error_are_text_only(field, value):
|
||||||
|
source = batch()
|
||||||
|
if field == "validity":
|
||||||
|
source[field] = value
|
||||||
|
else:
|
||||||
|
for model in source["models"].values():
|
||||||
|
if field == "status":
|
||||||
|
model[field] = value
|
||||||
|
else:
|
||||||
|
model[field]["status"] = value
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows
|
||||||
|
assert all(row.scale is None for row in rows)
|
||||||
|
assert all(not row.changed for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaccounted_candidates_and_missing_trace_survive():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["candidate"]["herbs"].append({"name": "未规范炮制药", "dosage": "2.5", "unit": "g"})
|
||||||
|
model["comparison"]["rows"][0]["candidate"].pop("source_rows")
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert len(rows) == 5
|
||||||
|
originals = [row for row in rows if next(iter(row.entries.values())).origin != "comparison"]
|
||||||
|
assert len(originals) == 4
|
||||||
|
assert all(row.scale is None for row in originals)
|
||||||
|
assert sum(row.name == "未规范炮制药" for row in originals) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_never_fabricated_from_summary():
|
||||||
|
assert diagnosis_text({"report": {"summary": "摘要疾病"}}) == "诊断意见未保存"
|
||||||
|
assert diagnosis_text({"report": {"diagnosis": "辨证原文"}}) == "辨证原文"
|
||||||
|
assert diagnosis_text({"report": {"diagnosis": {"western_diagnosis": "诊断原值"}}}) == "西医诊断:诊断原值"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_side_is_not_a_zero_dose_difference():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"] = None
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows[0].doctor is None
|
||||||
|
assert not rows[0].changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_report_states_itself_instead_of_reporting_zero_differences(workspace):
|
||||||
|
source = batch()
|
||||||
|
source["validity"] = "stale"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert "历史或失效报告" in workspace.summary_text
|
||||||
|
assert workspace.doses.rows == [] # 不可比就不画,不是画成 0
|
||||||
|
assert workspace.doses.empty.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_每味药与原方的距离按共同标尺画出(workspace):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
assert [delta.name for delta in workspace.doses.rows] == ["药材0", "药材1", "药材2"]
|
||||||
|
assert all(delta.deltas["qwen"] == -15 for delta in workspace.doses.rows)
|
||||||
|
assert "3 项剂量差异" in workspace.summary_text
|
||||||
|
axis = workspace.doses.body.findChildren(QWidget)
|
||||||
|
described = [widget.accessibleDescription() for widget in axis if widget.accessibleName() == "剂量差异条"]
|
||||||
|
assert "药材0 千问 −15克 OpenAI −15克" in described
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_herb_one_model_never_listed_is_marked_absent_not_zero(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["rows"][0]["candidate"] = None
|
||||||
|
source["models"]["openai"]["candidate"]["herbs"] = []
|
||||||
|
workspace.set_batch(source)
|
||||||
|
delta = workspace.doses.rows[0]
|
||||||
|
assert delta.deltas["openai"] is None
|
||||||
|
assert delta.badge == "OpenAI 未收录"
|
||||||
|
assert delta.deltas["qwen"] == -15
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_saved_dose_takes_the_whole_row_off_the_chart(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["rows"][0]["candidate"]["dosage"] = "nan"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.doses.rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_incomparable_basis_keeps_the_row_off_the_chart(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"]["dose_basis"] = "per_day"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.doses.rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pending_model_adds_no_row_and_no_difference(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"] = {"status": "running"}
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert len(workspace.doses.rows) == 1
|
||||||
|
assert workspace.doses.rows[0].deltas["openai"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_herbs_carry_their_full_dose_and_say_which_model_added_them(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"] = None
|
||||||
|
workspace.set_batch(source)
|
||||||
|
delta = workspace.doses.rows[0]
|
||||||
|
assert delta.doctor is None
|
||||||
|
assert delta.deltas["qwen"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_counts_split_the_saved_gaps_into_three_buckets() -> None:
|
||||||
|
source = batch(1)
|
||||||
|
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ATTACHMENT_STORAGE_RESTRICTED"},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE"}]
|
||||||
|
assert gap_counts(source) == {"critical": 1, "attachment": 1, "other": 1}
|
||||||
|
assert gap_counts(batch(1)) == {"critical": 0, "attachment": 0, "other": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _conclusion_texts(workspace) -> list[str]:
|
||||||
|
"""The numbered sentences, skipping the hairlines the design puts between them."""
|
||||||
|
|
||||||
|
texts = []
|
||||||
|
for index in range(workspace.conclusions.body_layout.count()):
|
||||||
|
widget = workspace.conclusions.body_layout.itemAt(index).widget()
|
||||||
|
labels = widget.findChildren(QLabel) if widget is not None else []
|
||||||
|
if labels:
|
||||||
|
texts.append(labels[-1].text())
|
||||||
|
return texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_conclusions_state_the_counts_they_were_built_from(workspace) -> None:
|
||||||
|
workspace.set_batch(batch(3))
|
||||||
|
texts = _conclusion_texts(workspace)
|
||||||
|
assert any("两个模型都给出了候选方" in text for text in texts)
|
||||||
|
assert any("剂量偏差共" in text for text in texts)
|
||||||
|
assert any("没有记录资料缺口" in text for text in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conclusions_never_invent_a_score_comparison(workspace) -> None:
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["status"] = "not_comparable"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
texts = _conclusion_texts(workspace)
|
||||||
|
assert any("只有可比的一侧有分数" in text for text in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribution_groups_every_herb_exactly_once(workspace) -> None:
|
||||||
|
source = batch(2)
|
||||||
|
source["models"]["openai"]["candidate"]["herbs"] = [{"name": "药材0", "dosage": "15.00", "unit": "g"}]
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.attribution.rows["all"]["count"].text() == "1 味"
|
||||||
|
assert workspace.attribution.rows["doctor_only"]["count"].text() == "0 味"
|
||||||
|
assert workspace.attribution.rows["openai_only"]["count"].text() == "0 味"
|
||||||
|
# 药材1 只有医方与千问共用,四个分组都不含它,标题必须说明这一点
|
||||||
|
assert "合计 2 味" in workspace.attribution.total_note.text()
|
||||||
|
assert "另 1 味为医方与单一模型共用" in workspace.attribution.total_note.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_panel_flags_only_large_changes(workspace) -> None:
|
||||||
|
small = batch(1)
|
||||||
|
for model in small["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = "29.00"
|
||||||
|
workspace.set_batch(small)
|
||||||
|
assert workspace.risks.empty.isVisible() # 差 1 克不进清单
|
||||||
|
large = batch(1)
|
||||||
|
for model in large["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = "12.00"
|
||||||
|
workspace.set_batch(large)
|
||||||
|
assert workspace.risks.body.isVisible()
|
||||||
|
assert "2 项" in workspace.risks.hint.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_checklist_merges_the_models_and_keeps_the_severe_items_first():
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["qwen"]["report"]["risk_assessment"] = [{"level": "high", "label": "血压数据缺失"}]
|
||||||
|
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}]
|
||||||
|
items = checklist_items(source)
|
||||||
|
assert items[0][0] == "血压数据缺失"
|
||||||
|
assert items[0][1] == "千问 关键"
|
||||||
|
shared = next(item for item in items if item[0] == "缺少舌脉记录")
|
||||||
|
assert shared[1] == "千问、OpenAI"
|
||||||
|
assert any("转写" in item[0] for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checklist_shows_the_saved_items_and_says_so_when_empty(workspace):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
assert "1 条 · 按严重度排序" in workspace.checklist.count_note.text()
|
||||||
|
assert not workspace.checklist.empty.isVisible()
|
||||||
|
workspace.set_batch({"id": 9, "validity": "current", "models": {}})
|
||||||
|
assert workspace.checklist.empty.isVisible()
|
||||||
|
assert workspace.checklist.count_note.text() == "暂无待确认项"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerendering_leaves_no_stale_row_widgets(workspace, application):
|
||||||
|
workspace.set_batch(batch(20))
|
||||||
|
application.processEvents()
|
||||||
|
assert len(_axes(workspace)) == 20
|
||||||
|
workspace.set_batch(batch(3))
|
||||||
|
application.processEvents()
|
||||||
|
assert len(_axes(workspace)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def _axes(workspace):
|
||||||
|
return [widget for widget in workspace.doses.body.findChildren(QWidget)
|
||||||
|
if widget.accessibleName() == "剂量差异条" and widget.parentWidget() is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_fits_a_1024_window_without_horizontal_scrolling(workspace, application):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
application.processEvents()
|
||||||
|
assert workspace.minimumSizeHint().width() <= 900
|
||||||
|
assert workspace.width() == 1024
|
||||||
|
assert workspace.doses.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
assert workspace.checklist.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
|
||||||
|
|
||||||
|
def test_dose_deltas_ignore_rows_without_a_shared_scale():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["unit"] = "mg"
|
||||||
|
rows, _states = review_rows(source)
|
||||||
|
assert dose_deltas(rows) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_slot_has_no_visible_parentless_widget(workspace, application):
|
||||||
|
before = {widget for widget in application.topLevelWidgets() if widget.isVisible()}
|
||||||
|
first = QLabel("第一模型复核", workspace)
|
||||||
|
second = QLabel("双模型复核", workspace)
|
||||||
|
workspace.set_review_widget(first)
|
||||||
|
workspace.set_review_widget(second)
|
||||||
|
application.processEvents()
|
||||||
|
assert second.parentWidget() is workspace.checklist.review_slot
|
||||||
|
assert not first.isVisible()
|
||||||
|
assert first.parentWidget() is workspace.checklist.review_slot
|
||||||
|
assert {widget for widget in application.topLevelWidgets() if widget.isVisible()} == before
|
||||||
|
|
||||||
|
|
||||||
@@ -279,8 +279,10 @@ def test_workspace_workers_use_gui_thread_query_snapshots(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||||
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
|
mode: str,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
) -> None:
|
) -> None:
|
||||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
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,
|
"doctor_id": 77,
|
||||||
}
|
}
|
||||||
dialog = _AppointmentDialog(row, repository=Repository())
|
dialog = _AppointmentDialog(row, repository=Repository())
|
||||||
|
dialog.appointment_type.setCurrentIndex(dialog.appointment_type.findData(mode))
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
|
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
|
||||||
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
|
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_date": tomorrow,
|
||||||
"appointment_time": "09:30-10:00",
|
"appointment_time": "09:30-10:00",
|
||||||
"period": "all",
|
"period": "all",
|
||||||
"appointment_type": "video",
|
"appointment_type": mode,
|
||||||
"remark": "复诊预约",
|
"remark": "复诊预约",
|
||||||
"channel_source": "online",
|
"channel_source": "online",
|
||||||
"channel_source_detail": "",
|
"channel_source_detail": "",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from doctor_workstation.ui.pages import prescription_library as library_module
|
|||||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||||
from doctor_workstation.ui.widgets import BusinessPager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
|||||||
page.table.selectRow(0)
|
page.table.selectRow(0)
|
||||||
page._selection_changed()
|
page._selection_changed()
|
||||||
|
|
||||||
assert page.table.columnCount() == 11
|
assert page.table.columnCount() == 13
|
||||||
|
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
|
||||||
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
||||||
assert page.table.cellWidget(0, 2) is not None
|
assert page.table.cellWidget(0, 2) is not None
|
||||||
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
|
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ def test_shell_preserves_all_columns_filters_and_reachable_pager(
|
|||||||
assert [column.key for column in table.columns] == [
|
assert [column.key for column in table.columns] == [
|
||||||
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
||||||
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
||||||
|
"__ai_status__", "__ai_agreement__",
|
||||||
]
|
]
|
||||||
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
||||||
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
||||||
|
|||||||
@@ -2565,11 +2565,14 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", [None, "text"])
|
||||||
def test_video_payload_keeps_three_identifiers_distinct(
|
def test_video_payload_keeps_three_identifiers_distinct(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
|
mode: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
detail = _detail(41, name="视频患者")
|
detail = _detail(41, name="视频患者")
|
||||||
|
detail["appointment"]["appointment_type"] = mode
|
||||||
|
|
||||||
class Repository:
|
class Repository:
|
||||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||||
@@ -2586,6 +2589,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
|||||||
{
|
{
|
||||||
"source": "reception",
|
"source": "reception",
|
||||||
"appointment_id": 41,
|
"appointment_id": 41,
|
||||||
|
"appointment_type": mode,
|
||||||
"patient_id": 141,
|
"patient_id": 141,
|
||||||
"diagnosis_id": 241,
|
"diagnosis_id": 241,
|
||||||
"patient_name": "视频患者",
|
"patient_name": "视频患者",
|
||||||
@@ -2593,6 +2597,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
|||||||
"record": detail["appointment"],
|
"record": detail["appointment"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
assert page.video_button.text() == ("图文沟通" if mode == "text" else "IM 问诊")
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
|||||||
assert client.post_calls == [
|
assert client.post_calls == [
|
||||||
(
|
(
|
||||||
"tcm.diagnosis/startCall",
|
"tcm.diagnosis/startCall",
|
||||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2, "appointment_id": 0},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Appointment policy must survive repository, launcher and lifecycle boundaries."""
|
||||||
|
import logging
|
||||||
|
from types import MethodType, SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from doctor_workstation.core.models import CallTicket
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.video.launcher import normalize_backend_ticket
|
||||||
|
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
|
||||||
|
|
||||||
|
|
||||||
|
def ticket(**policy):
|
||||||
|
return CallTicket.from_dict({
|
||||||
|
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||||
|
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||||
|
"appointment_id": 456, "appointment_type": "text", **policy,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, False, 0, 1, "true", "1", []])
|
||||||
|
def test_only_actual_server_true_authorizes_calls(value):
|
||||||
|
request = normalize_backend_ticket(ticket(can_video_call=value, can_audio_call=value))
|
||||||
|
assert request.appointment_id == 456
|
||||||
|
assert request.appointment_type == "text"
|
||||||
|
assert request.patient_id == 8
|
||||||
|
assert request.to_web_config()["can_video_call"] is False
|
||||||
|
assert request.to_web_config()["can_audio_call"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_policy_preserved_and_missing_policy_denies():
|
||||||
|
request = normalize_backend_ticket(ticket())
|
||||||
|
assert not request.can_video_call and not request.can_audio_call
|
||||||
|
request = normalize_backend_ticket(ticket(appointment_type="phone", can_audio_call=True))
|
||||||
|
assert request.can_audio_call and not request.can_video_call
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_forwards_exact_appointment_and_media_type():
|
||||||
|
class Client:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def post(self, endpoint, payload):
|
||||||
|
self.calls.append((endpoint, payload))
|
||||||
|
return {"call_record_id": 99} if endpoint.endswith("startCall") else ticket().raw
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
repository = RemoteDoctorRepository(client)
|
||||||
|
assert repository.get_call_ticket(8, 123, appointment_id=456).raw["appointment_type"] == "text"
|
||||||
|
repository.start_call(123, 8, call_type=1, appointment_id=456)
|
||||||
|
assert client.calls == [
|
||||||
|
("tcm.diagnosis/getCallSignature", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}),
|
||||||
|
("tcm.diagnosis/startCall", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456, "call_type": 1}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_refreshes_exact_identity_and_uses_actual_media():
|
||||||
|
class Repository:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.appointment_id = 456
|
||||||
|
|
||||||
|
def get_call_ticket(self, **payload):
|
||||||
|
self.calls.append(payload)
|
||||||
|
return ticket(appointment_id=self.appointment_id, can_audio_call=True)
|
||||||
|
|
||||||
|
def start_call(self, **payload):
|
||||||
|
self.calls.append(payload)
|
||||||
|
return {"call_record_id": 99}
|
||||||
|
|
||||||
|
def end_call(self, **payload):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
repository = Repository()
|
||||||
|
lifecycle = OrderedCallLifecycle(normalize_backend_ticket(ticket()), repository, logging.getLogger(__name__))
|
||||||
|
try:
|
||||||
|
assert lifecycle.refresh_call_policy().result(timeout=2)["can_audio_call"] is True
|
||||||
|
assert repository.calls[-1] == {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}
|
||||||
|
assert lifecycle.start(call_type=1).result(timeout=2)
|
||||||
|
assert repository.calls[-1]["call_type"] == 1
|
||||||
|
assert repository.calls[-1]["appointment_id"] == 456
|
||||||
|
repository.appointment_id = 457
|
||||||
|
with pytest.raises(ValueError, match="当前问诊已变更"):
|
||||||
|
lifecycle.refresh_call_policy().result(timeout=2)
|
||||||
|
finally:
|
||||||
|
lifecycle.end("test").result(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_controller(monkeypatch):
|
||||||
|
"""Exercise the real controller callbacks without launching Qt or Tencent."""
|
||||||
|
from doctor_workstation import app as app_module
|
||||||
|
|
||||||
|
queued = []
|
||||||
|
launched = []
|
||||||
|
previews = []
|
||||||
|
diagnoses = []
|
||||||
|
dialog = object()
|
||||||
|
|
||||||
|
def get_call_ticket(patient_id, diagnosis_id, *, appointment_id):
|
||||||
|
return ticket(patient_id=patient_id, diagnosis_id=diagnosis_id, appointment_id=appointment_id)
|
||||||
|
|
||||||
|
def launch_video_call(_ticket, **kwargs):
|
||||||
|
window = SimpleNamespace(
|
||||||
|
show=lambda: None,
|
||||||
|
raise_=lambda: None,
|
||||||
|
activateWindow=lambda: None,
|
||||||
|
destroyed=SimpleNamespace(connect=lambda _callback: None),
|
||||||
|
)
|
||||||
|
call = SimpleNamespace(open_im=kwargs["open_im"], qt_window=window, close=lambda: None)
|
||||||
|
launched.append((call, kwargs))
|
||||||
|
return call
|
||||||
|
|
||||||
|
def open_diagnosis_by_id(diagnosis_id, *, modeless):
|
||||||
|
diagnoses.append((diagnosis_id, modeless))
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||||
|
monkeypatch.setattr(app_module, "launch_video_call", launch_video_call)
|
||||||
|
monkeypatch.setattr(app_module, "show_toast", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(app_module, "_build_video_patient_case", lambda *_args, **_kwargs: {})
|
||||||
|
monkeypatch.setattr(app_module, "WEBENGINE_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(app_module, "QTimer", SimpleNamespace(singleShot=lambda _delay, callback: callback()))
|
||||||
|
controller = SimpleNamespace(
|
||||||
|
shell_window=SimpleNamespace(open_diagnosis_by_id=open_diagnosis_by_id),
|
||||||
|
current_repository=SimpleNamespace(get_call_ticket=get_call_ticket),
|
||||||
|
current_demo_mode=False,
|
||||||
|
video_calls={}, video_pending={}, demo_video_dialogs={}, _pending_im_request=None,
|
||||||
|
config=SimpleNamespace(video_mode="embedded", video_web_url=""),
|
||||||
|
_show_video_preview=lambda window, diagnosis_dialog: previews.append((window, diagnosis_dialog)),
|
||||||
|
)
|
||||||
|
for method in ("_request_video", "_launch_video", "_video_ticket_error", "_open_video_diagnosis"):
|
||||||
|
setattr(controller, method, MethodType(getattr(app_module.ApplicationController, method), controller))
|
||||||
|
|
||||||
|
def request(appointment_id, diagnosis_id=123):
|
||||||
|
controller._request_video({
|
||||||
|
"patient_id": 8, "diagnosis_id": diagnosis_id,
|
||||||
|
"appointment_id": appointment_id, "mode": "im",
|
||||||
|
})
|
||||||
|
|
||||||
|
def complete(index):
|
||||||
|
function, callbacks = queued[index]
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
controller=controller, request=request, complete=complete, queued=queued,
|
||||||
|
launched=launched, previews=previews, diagnoses=diagnoses, dialog=dialog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
|
||||||
|
case = chat_controller
|
||||||
|
case.request(456)
|
||||||
|
case.complete(0)
|
||||||
|
call, launch_args = case.launched[0]
|
||||||
|
case.controller.video_calls["123:457"] = SimpleNamespace(qt_window=object())
|
||||||
|
|
||||||
|
launch_args["on_open_diagnosis"]()
|
||||||
|
assert case.diagnoses == [(123, True)]
|
||||||
|
assert case.previews == [(call.qt_window, case.dialog)]
|
||||||
|
|
||||||
|
case.controller.video_calls.pop("123:456")
|
||||||
|
launch_args["on_open_diagnosis"]()
|
||||||
|
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("second_diagnosis", [123, 124])
|
||||||
|
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
|
||||||
|
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
|
||||||
|
case = chat_controller
|
||||||
|
case.request(456)
|
||||||
|
case.request(457, diagnosis_id=second_diagnosis)
|
||||||
|
assert list(case.controller.video_pending) == [f"{second_diagnosis}:457"]
|
||||||
|
|
||||||
|
for index in completion_order:
|
||||||
|
case.complete(index)
|
||||||
|
|
||||||
|
assert list(case.controller.video_calls) == [f"{second_diagnosis}:457"]
|
||||||
|
assert len(case.launched) == 1
|
||||||
|
assert case.controller.video_pending == {}
|
||||||
|
assert case.controller._pending_im_request is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reselecting_open_im_also_retires_other_pending_selection(chat_controller):
|
||||||
|
case = chat_controller
|
||||||
|
case.request(456)
|
||||||
|
case.complete(0)
|
||||||
|
original_call = case.launched[0][0]
|
||||||
|
case.request(457)
|
||||||
|
# The previous page may still be finishing its native shutdown. Treat a
|
||||||
|
# reactivated current page like any other current selection.
|
||||||
|
case.controller.video_calls["123:456"] = original_call
|
||||||
|
case.request(456)
|
||||||
|
case.complete(1)
|
||||||
|
assert list(case.controller.video_calls) == ["123:456"]
|
||||||
|
assert case.controller.video_pending == {}
|
||||||
|
assert len(case.launched) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_pending_im_selection_does_not_duplicate_request(chat_controller):
|
||||||
|
case = chat_controller
|
||||||
|
case.request(456)
|
||||||
|
case.request(456)
|
||||||
|
assert len(case.queued) == 1
|
||||||
|
case.complete(0)
|
||||||
|
assert list(case.controller.video_calls) == ["123:456"]
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_signature_repository(**changes):
|
||||||
|
response = {
|
||||||
|
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||||
|
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||||
|
**changes,
|
||||||
|
}
|
||||||
|
repository = RemoteDoctorRepository(SimpleNamespace(post=lambda *_args: response))
|
||||||
|
repository.patient_detail = lambda _diagnosis_id: {}
|
||||||
|
return repository, response
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_signature_opens_im_with_requested_context_and_no_media(chat_controller):
|
||||||
|
case = chat_controller
|
||||||
|
repository, response = legacy_signature_repository()
|
||||||
|
case.controller.current_repository = repository
|
||||||
|
case.request(456)
|
||||||
|
case.complete(0)
|
||||||
|
assert list(case.controller.video_calls) == ["123:456"]
|
||||||
|
assert case.launched[0][1]["open_im"] is True
|
||||||
|
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||||
|
assert request.appointment_id == 456
|
||||||
|
assert request.target_user_id == "patient_8"
|
||||||
|
assert request.can_video_call is False and request.can_audio_call is False
|
||||||
|
assert request.call_disabled_reason
|
||||||
|
assert "appointment_id" not in response # Never mutate the HTTP response/shared cache.
|
||||||
|
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
|
||||||
|
try:
|
||||||
|
refreshed = lifecycle.refresh_call_policy().result(timeout=2)
|
||||||
|
assert refreshed["appointmentId"] == 456
|
||||||
|
assert refreshed["can_video_call"] is False and refreshed["can_audio_call"] is False
|
||||||
|
finally:
|
||||||
|
lifecycle.end("test").result(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("changes", [
|
||||||
|
{"diagnosis_id": 124}, {"patient_id": 9}, {"patientUserId": "patient_9"},
|
||||||
|
{"diagnosis_id": None}, {"patient_id": None}, {"patientUserId": None},
|
||||||
|
])
|
||||||
|
def test_legacy_signature_requires_full_matching_identity(changes):
|
||||||
|
repository, _ = legacy_signature_repository(**changes)
|
||||||
|
with pytest.raises(ValueError, match="患者或诊单不匹配"):
|
||||||
|
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("returned_id", [457, 0, None, "", True, -1, 456.9, 456.0, "bad"])
|
||||||
|
def test_explicit_wrong_or_invalid_appointment_is_never_replaced(returned_id):
|
||||||
|
repository, _ = legacy_signature_repository(appointment_id=returned_id)
|
||||||
|
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||||
|
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fragment", [
|
||||||
|
{"can_video_call": True}, {"can_audio_call": True}, {"appointment_type": "text"},
|
||||||
|
{"appointmentId": 457}, {"call_disabled_reason": ""},
|
||||||
|
])
|
||||||
|
def test_partial_policy_is_not_treated_as_legacy_signature(fragment):
|
||||||
|
repository, _ = legacy_signature_repository(**fragment)
|
||||||
|
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||||
|
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("returned_id", [456, "456"])
|
||||||
|
def test_modern_exact_appointment_policy_is_preserved(returned_id):
|
||||||
|
repository, _ = legacy_signature_repository(
|
||||||
|
appointment_id=returned_id, appointment_type="video", can_video_call=True, can_audio_call=True,
|
||||||
|
)
|
||||||
|
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||||
|
assert request.appointment_id == 456
|
||||||
|
assert request.can_video_call is True and request.can_audio_call is True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Appointment medium stays attached to its own row and opens text chat without RTC."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.core.appointment_modes import (
|
||||||
|
appointment_type_description,
|
||||||
|
appointment_type_value,
|
||||||
|
can_appointment_video,
|
||||||
|
)
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import _blue_appointment_text
|
||||||
|
from doctor_workstation.ui.pages import appointments, consultations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,label,video", [
|
||||||
|
("text", "图文问诊", False), ("video", "视频问诊", True),
|
||||||
|
(None, "视频问诊", True), (" ", "视频问诊", True),
|
||||||
|
("phone", "电话问诊", False), ("unexpected", "未知", False),
|
||||||
|
])
|
||||||
|
def test_appointment_type_never_uses_diagnosis_visit_type(value, label, video):
|
||||||
|
assert appointment_type_description(value) == label
|
||||||
|
assert can_appointment_video(value) is video
|
||||||
|
assert appointment_type_value({"appointment_type": value, "consultation_type": "复诊"}) == value or value == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_type_uses_current_appointment_id_not_another_latest():
|
||||||
|
row = {"appointment_id": 12, "latest_appointment_type": "video", "appointments": [
|
||||||
|
{"id": 11, "appointment_type": "video"}, {"id": 12, "appointment_type": "text"},
|
||||||
|
]}
|
||||||
|
assert appointment_type_value(row) == "text"
|
||||||
|
assert "图文问诊" in _blue_appointment_text(row, row["appointments"][1])[1]
|
||||||
|
assert "视频问诊" in _blue_appointment_text(row, row["appointments"][0])[1]
|
||||||
|
assert appointment_type_value({"appointments": row["appointments"]}) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(appointments, "run_async", lambda *_a, **_kw: None)
|
||||||
|
notices = []
|
||||||
|
monkeypatch.setattr(appointments, "show_toast", lambda _parent, message, *_a: notices.append(message))
|
||||||
|
page = appointments.AppointmentsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||||
|
rows = [{"id": 71, "patient_id": 501, "diagnosis_id": 501, "source_patient_id": 901,
|
||||||
|
"patient_name": "图文患者", "doctor_id": 7, "status": 1, "appointment_type": "text"},
|
||||||
|
{"id": 72, "patient_id": 502, "diagnosis_id": 502, "source_patient_id": 902,
|
||||||
|
"patient_name": "视频患者", "doctor_id": 8, "status": 1, "appointment_type": "video"}]
|
||||||
|
try:
|
||||||
|
page._loaded({"lists": rows, "count": 2}, page._generation, False)
|
||||||
|
emitted = []
|
||||||
|
page.video_requested.connect(emitted.append)
|
||||||
|
page.table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||||
|
text_row = next(i for i in range(2) if page.table.item(i, 0).data(Qt.ItemDataRole.UserRole)["id"] == 71)
|
||||||
|
button = page.table.cellWidget(text_row, 10).findChild(QPushButton, "AppointmentImConsultButton")
|
||||||
|
assert button.text() == "图文沟通" and button.isEnabled()
|
||||||
|
assert page.table.cellWidget(text_row, 4).findChild(QLabel, "AppointmentModeLabel").text() == "图文问诊"
|
||||||
|
button.click()
|
||||||
|
assert emitted[-1]["appointment_id"] == 71
|
||||||
|
assert emitted[-1]["diagnosis_id"] == 501
|
||||||
|
assert emitted[-1]["patient_id"] == 901
|
||||||
|
assert emitted[-1]["appointment_type"] == "text"
|
||||||
|
assert emitted[-1]["mode"] == "im"
|
||||||
|
assert not page.toolbar_qr_button.isEnabled()
|
||||||
|
page._request_video_qr()
|
||||||
|
assert notices[-1] == "本次挂号不支持视频问诊二维码。"
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
||||||
|
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
||||||
|
"appointment_status": 1, "latest_appointment_id": 71,
|
||||||
|
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
||||||
|
"video_call_hint": {"state": "none"}}
|
||||||
|
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||||
|
try:
|
||||||
|
page.table_host.set_rows([row])
|
||||||
|
page.table.selectRow(0)
|
||||||
|
page._selection_changed()
|
||||||
|
buttons = page.table_host.findChildren(QToolButton)
|
||||||
|
text_button = next(button for button in buttons if button.text() == "图文沟通")
|
||||||
|
assert text_button.isEnabled()
|
||||||
|
assert page.video_button.isEnabled()
|
||||||
|
assert not page.video_qr_toolbar_button.isEnabled()
|
||||||
|
emitted = []
|
||||||
|
page.video_requested.connect(emitted.append)
|
||||||
|
page._request_video()
|
||||||
|
assert emitted[-1]["appointment_id"] == 71
|
||||||
|
assert emitted[-1]["appointment_type"] == "text"
|
||||||
|
assert emitted[-1]["mode"] == "im"
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
@@ -642,6 +642,13 @@ def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
|||||||
"userSig": "short-lived-ticket",
|
"userSig": "short-lived-ticket",
|
||||||
"targetUserId": "patient_8",
|
"targetUserId": "patient_8",
|
||||||
"diagnosisId": 123,
|
"diagnosisId": 123,
|
||||||
|
"patientId": 8,
|
||||||
|
"appointmentId": 0,
|
||||||
|
"appointment_type": None,
|
||||||
|
"appointment_type_desc": "",
|
||||||
|
"can_video_call": False,
|
||||||
|
"can_audio_call": False,
|
||||||
|
"call_disabled_reason": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ def test_format_record_time_with_datetime_returns_minute_precision() -> None:
|
|||||||
assert format_record_time(date(2026, 8, 20)) == "2026-08-20"
|
assert format_record_time(date(2026, 8, 20)) == "2026-08-20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_record_time_with_unset_epoch_returns_default() -> None:
|
||||||
|
# The API sends 0 (and occasionally "0") for a snapshot cutoff or finish time that does
|
||||||
|
# not exist yet; it must not be rendered as a bare 0 or as 1970.
|
||||||
|
assert format_record_time(0) == "—"
|
||||||
|
assert format_record_time("0") == "—"
|
||||||
|
assert format_record_time(-1) == "—"
|
||||||
|
assert format_record_time(0, default="未开始") == "未开始"
|
||||||
|
|
||||||
|
|
||||||
def test_format_record_time_with_garbage_returns_raw_value() -> None:
|
def test_format_record_time_with_garbage_returns_raw_value() -> None:
|
||||||
assert format_record_time("not-a-timestamp") == "not-a-timestamp"
|
assert format_record_time("not-a-timestamp") == "not-a-timestamp"
|
||||||
|
|
||||||
|
|||||||
+91
-91
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||||
<title>视频面诊</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const props = defineProps<{
|
|||||||
chatReady: Readonly<Ref<boolean>>
|
chatReady: Readonly<Ref<boolean>>
|
||||||
chatBusy: Readonly<Ref<boolean>>
|
chatBusy: Readonly<Ref<boolean>>
|
||||||
notice: Readonly<Ref<string>>
|
notice: Readonly<Ref<string>>
|
||||||
|
canVideoCall: Readonly<Ref<boolean>>
|
||||||
|
callDisabledReason: Readonly<Ref<string>>
|
||||||
hasMoreMessages: Readonly<Ref<boolean>>
|
hasMoreMessages: Readonly<Ref<boolean>>
|
||||||
transcriptionState: Readonly<Ref<string>>
|
transcriptionState: Readonly<Ref<string>>
|
||||||
localRecordingState: Readonly<Ref<string>>
|
localRecordingState: Readonly<Ref<string>>
|
||||||
@@ -308,6 +310,7 @@ watch(
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="primary-action"
|
class="primary-action"
|
||||||
|
v-if="canVideoCall.value"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="actionBusy || isCalling || !chatReady.value"
|
:disabled="actionBusy || isCalling || !chatReady.value"
|
||||||
@click="runAction(onStartVideo)"
|
@click="runAction(onStartVideo)"
|
||||||
@@ -383,6 +386,9 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="composer">
|
<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 }">
|
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
|
||||||
{{ localError || notice.value }}
|
{{ localError || notice.value }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
type Guard = (callType: number) => Promise<void>
|
||||||
|
|
||||||
|
/** Each SDK entrypoint uses the current conversation, including after async work. */
|
||||||
|
export function installAppointmentCallGuard(api: Record<string, any>): (guard: Guard) => () => void {
|
||||||
|
let activeGuard: Guard | undefined
|
||||||
|
for (const method of ['call', 'calls', 'groupCall']) {
|
||||||
|
const original = api[method]
|
||||||
|
if (typeof original !== 'function') continue
|
||||||
|
api[method] = async function (params: any, ...rest: any[]) {
|
||||||
|
const guard = activeGuard
|
||||||
|
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
|
||||||
|
const type = params?.type === 1 ? 1 : 2
|
||||||
|
const dispatched = { ...params, type }
|
||||||
|
await guard(type)
|
||||||
|
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
|
||||||
|
return original.call(this, dispatched, ...rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (guard: Guard) => {
|
||||||
|
activeGuard = guard
|
||||||
|
return () => { if (activeGuard === guard) activeGuard = undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCallPolicy(policy: Record<string, unknown>, type: number): void {
|
||||||
|
if ((type === 1 ? policy.can_audio_call : policy.can_video_call) !== true) {
|
||||||
|
throw new Error(String(policy.call_disabled_reason || '本次挂号不支持该通话方式'))
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -6,6 +6,12 @@ declare module 'tim-upload-plugin' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DoctorCallConfig {
|
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
|
||||||
sdkAppId?: number | string
|
sdkAppId?: number | string
|
||||||
userID?: string
|
userID?: string
|
||||||
@@ -53,6 +59,7 @@ interface DoctorConsultationApi {
|
|||||||
startVideo(): Promise<void>
|
startVideo(): Promise<void>
|
||||||
hangup(): Promise<void>
|
hangup(): Promise<void>
|
||||||
hostCallReady(ok: boolean, message?: string): void
|
hostCallReady(ok: boolean, message?: string): void
|
||||||
|
callPolicyResult(requestId: string, policy: Record<string, unknown>): void
|
||||||
recordingResult(ok: boolean, message: string): void
|
recordingResult(ok: boolean, message: string): void
|
||||||
roomBindingResult(roomId: string, ok: boolean, message: string): void
|
roomBindingResult(roomId: string, ok: boolean, message: string): void
|
||||||
screenshotResult(ok: boolean, message: string): void
|
screenshotResult(ok: boolean, message: string): void
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@trtc/calls-uikit-vue'
|
} from '@trtc/calls-uikit-vue'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { assertCallPolicy, installAppointmentCallGuard } from './appointment-call-guard'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
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'
|
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
|
||||||
|
|
||||||
interface NormalizedCallConfig {
|
interface NormalizedCallConfig {
|
||||||
|
appointmentId: number
|
||||||
|
policy: Record<string, unknown>
|
||||||
SDKAppID: number
|
SDKAppID: number
|
||||||
userID: string
|
userID: string
|
||||||
userSig: string
|
userSig: string
|
||||||
@@ -77,6 +80,7 @@ interface BridgeMessage {
|
|||||||
event:
|
event:
|
||||||
| 'ready'
|
| 'ready'
|
||||||
| 'call-start-request'
|
| 'call-start-request'
|
||||||
|
| 'call-policy-request'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'room'
|
| 'room'
|
||||||
| 'hangup'
|
| 'hangup'
|
||||||
@@ -86,6 +90,8 @@ interface BridgeMessage {
|
|||||||
| 'transcription-segment'
|
| 'transcription-segment'
|
||||||
| 'transcription-stop'
|
| 'transcription-stop'
|
||||||
diagnosisId?: number | string
|
diagnosisId?: number | string
|
||||||
|
callType?: number
|
||||||
|
requestId?: string
|
||||||
status?: string
|
status?: string
|
||||||
roomId?: string
|
roomId?: string
|
||||||
message?: string
|
message?: string
|
||||||
@@ -179,6 +185,13 @@ const messages = ref<UiChatMessage[]>([])
|
|||||||
const chatReady = ref(false)
|
const chatReady = ref(false)
|
||||||
const chatBusy = ref(false)
|
const chatBusy = ref(false)
|
||||||
const notice = ref('')
|
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 hasMoreMessages = ref(false)
|
||||||
const transcriptionState = ref<TranscriptionState>('idle')
|
const transcriptionState = ref<TranscriptionState>('idle')
|
||||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||||
@@ -381,6 +394,13 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
|||||||
: '患者'
|
: '患者'
|
||||||
return {
|
return {
|
||||||
SDKAppID,
|
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'),
|
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||||
userSig: cleanString(config.userSig, '用户签名'),
|
userSig: cleanString(config.userSig, '用户签名'),
|
||||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||||
@@ -1740,13 +1760,42 @@ TUICallKitAPI.setCallback({
|
|||||||
TUICallKitAPI.setLanguage('zh-cn')
|
TUICallKitAPI.setLanguage('zh-cn')
|
||||||
TUICallKitAPI.enableFloatWindow(false)
|
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 (!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) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const currentResolver = resolve
|
const currentResolver = resolve
|
||||||
resolveHostCallReady = currentResolver
|
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(() => {
|
window.setTimeout(() => {
|
||||||
if (resolveHostCallReady !== currentResolver) return
|
if (resolveHostCallReady !== currentResolver) return
|
||||||
resolveHostCallReady = null
|
resolveHostCallReady = null
|
||||||
@@ -1765,9 +1814,12 @@ function hostCallReady(ok: boolean, message = ''): void {
|
|||||||
async function startVideo(): Promise<void> {
|
async function startVideo(): Promise<void> {
|
||||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||||
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||||
|
const config = activeConfig
|
||||||
|
const generation = contextGeneration
|
||||||
starting = true
|
starting = true
|
||||||
try {
|
try {
|
||||||
if (hangupNotification) await hangupNotification
|
if (hangupNotification) await hangupNotification
|
||||||
|
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||||
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
|
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
|
||||||
hangupNotification = null
|
hangupNotification = null
|
||||||
callCycleGeneration += 1
|
callCycleGeneration += 1
|
||||||
@@ -1782,8 +1834,6 @@ async function startVideo(): Promise<void> {
|
|||||||
localRecordingSessionId = ''
|
localRecordingSessionId = ''
|
||||||
localRecordingMimeType = ''
|
localRecordingMimeType = ''
|
||||||
notice.value = ''
|
notice.value = ''
|
||||||
const allowed = await requestHostCallStart()
|
|
||||||
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
|
||||||
await TUICallKitAPI.init({
|
await TUICallKitAPI.init({
|
||||||
SDKAppID: activeConfig.SDKAppID,
|
SDKAppID: activeConfig.SDKAppID,
|
||||||
userID: activeConfig.userID,
|
userID: activeConfig.userID,
|
||||||
@@ -1791,6 +1841,7 @@ async function startVideo(): Promise<void> {
|
|||||||
...(chat ? { tim: chat, isFromChat: true } : {}),
|
...(chat ? { tim: chat, isFromChat: true } : {}),
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||||
phase.value = 'dialing'
|
phase.value = 'dialing'
|
||||||
statusText.value = '正在呼叫患者'
|
statusText.value = '正在呼叫患者'
|
||||||
appendVideoCallStatus('dialing', '正在呼叫患者')
|
appendVideoCallStatus('dialing', '正在呼叫患者')
|
||||||
@@ -1807,11 +1858,12 @@ async function startVideo(): Promise<void> {
|
|||||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = safeErrorMessage(error, '无法发起视频通话')
|
const message = safeErrorMessage(error, '无法发起视频通话')
|
||||||
|
if (activeConfig !== config || generation !== contextGeneration) throw new Error(message)
|
||||||
phase.value = 'error'
|
phase.value = 'error'
|
||||||
statusText.value = message
|
statusText.value = message
|
||||||
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
|
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
|
||||||
endNotified = true
|
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)
|
throw new Error(message)
|
||||||
} finally {
|
} finally {
|
||||||
starting = false
|
starting = false
|
||||||
@@ -1867,6 +1919,31 @@ function roomBindingResult(roomId: string, ok: boolean, message: string): void {
|
|||||||
async function open(config: DoctorCallConfig): Promise<void> {
|
async function open(config: DoctorCallConfig): Promise<void> {
|
||||||
if (activeConfig) await close()
|
if (activeConfig) await close()
|
||||||
activeConfig = normalizeConfig(config)
|
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
|
mode.value = activeConfig.mode
|
||||||
patientName.value = activeConfig.patientName
|
patientName.value = activeConfig.patientName
|
||||||
patientCase.value = activeConfig.patientCase
|
patientCase.value = activeConfig.patientCase
|
||||||
@@ -1908,6 +1985,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function close(): 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 (!endNotified) await hangup()
|
||||||
if (hangupNotification) await hangupNotification
|
if (hangupNotification) await hangupNotification
|
||||||
unsubscribeTranscriber()
|
unsubscribeTranscriber()
|
||||||
@@ -1933,6 +2017,7 @@ window.doctorConsultation = {
|
|||||||
startVideo,
|
startVideo,
|
||||||
hangup,
|
hangup,
|
||||||
hostCallReady,
|
hostCallReady,
|
||||||
|
callPolicyResult,
|
||||||
recordingResult,
|
recordingResult,
|
||||||
roomBindingResult,
|
roomBindingResult,
|
||||||
screenshotResult,
|
screenshotResult,
|
||||||
@@ -1951,6 +2036,8 @@ createApp(App, {
|
|||||||
chatReady: readonly(chatReady),
|
chatReady: readonly(chatReady),
|
||||||
chatBusy: readonly(chatBusy),
|
chatBusy: readonly(chatBusy),
|
||||||
notice: readonly(notice),
|
notice: readonly(notice),
|
||||||
|
canVideoCall: readonly(canVideoCall),
|
||||||
|
callDisabledReason: readonly(callDisabledReason),
|
||||||
hasMoreMessages: readonly(hasMoreMessages),
|
hasMoreMessages: readonly(hasMoreMessages),
|
||||||
transcriptionState: readonly(transcriptionState),
|
transcriptionState: readonly(transcriptionState),
|
||||||
localRecordingState: readonly(localRecordingState),
|
localRecordingState: readonly(localRecordingState),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../src/appointment-call-guard.ts'), 'utf8')
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
const target = { exports: {} }
|
||||||
|
new Function('module', 'exports', compiled)(target, target.exports)
|
||||||
|
const { installAppointmentCallGuard, assertCallPolicy } = target.exports
|
||||||
|
|
||||||
|
for (const method of ['call', 'calls', 'groupCall']) {
|
||||||
|
test(`${method}: current server policy controls every invocation`, async () => {
|
||||||
|
const dispatched = []
|
||||||
|
const api = { [method]: async (params) => dispatched.push(params.type) }
|
||||||
|
const register = installAppointmentCallGuard(api)
|
||||||
|
await assert.rejects(api[method]({ type: 2 }), /请先打开/)
|
||||||
|
let serverPolicy = { can_video_call: true, can_audio_call: true }
|
||||||
|
let refreshes = 0
|
||||||
|
register(async (type) => { refreshes++; assertCallPolicy(serverPolicy, type) })
|
||||||
|
await api[method]({ type: 2 })
|
||||||
|
serverPolicy = { appointment_type: 'text', can_audio_call: false, can_video_call: false }
|
||||||
|
await assert.rejects(api[method]({ type: 2 }))
|
||||||
|
await assert.rejects(api[method]({ type: 1 }))
|
||||||
|
assert.deepEqual(dispatched, [2])
|
||||||
|
assert.equal(refreshes, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${method}: close or replacement rejects an in-flight authorization`, async () => {
|
||||||
|
let resume
|
||||||
|
let dispatched = 0
|
||||||
|
const api = { [method]: async () => dispatched++ }
|
||||||
|
const register = installAppointmentCallGuard(api)
|
||||||
|
const release = register(() => new Promise((resolve) => { resume = resolve }))
|
||||||
|
const pending = api[method]({ type: 2 })
|
||||||
|
release()
|
||||||
|
register(async () => {})
|
||||||
|
resume()
|
||||||
|
await assert.rejects(pending, /已切换/)
|
||||||
|
assert.equal(dispatched, 0)
|
||||||
|
release() // Releasing the old window cannot remove the replacement guard.
|
||||||
|
await api[method]({ type: 1 })
|
||||||
|
assert.equal(dispatched, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('strict booleans fail closed, audio and video permissions remain separate', () => {
|
||||||
|
for (const value of [undefined, null, false, 0, 1, 'true', '1']) {
|
||||||
|
assert.throws(() => assertCallPolicy({ can_video_call: value }, 2))
|
||||||
|
assert.throws(() => assertCallPolicy({ can_audio_call: value }, 1))
|
||||||
|
}
|
||||||
|
assert.doesNotThrow(() => assertCallPolicy({ can_audio_call: true }, 1))
|
||||||
|
assert.throws(() => assertCallPolicy({ can_audio_call: true }, 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an awaiting caller cannot change the authorized media type', async () => {
|
||||||
|
let resume
|
||||||
|
let actual
|
||||||
|
const api = { calls: async (params) => { actual = params.type } }
|
||||||
|
const register = installAppointmentCallGuard(api)
|
||||||
|
register(() => new Promise((resolve) => { resume = resolve }))
|
||||||
|
const params = { type: 1 }
|
||||||
|
const pending = api.calls(params)
|
||||||
|
params.type = 2
|
||||||
|
resume()
|
||||||
|
await pending
|
||||||
|
assert.equal(actual, 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 挂号类型展示与图文问诊通话限制
|
||||||
|
|
||||||
|
按用户确认,仅使用现有视频问诊、图文问诊,不新增线下面诊选项。新预约默认视频,历史电话记录继续准确展示。
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
|
||||||
|
- 挂号编辑和预约抽屉明确标注“挂号类型”。我的患者、诊单 PC/H5 显示当前挂号类型,多次挂号分别展示;未挂号显示 `—`。
|
||||||
|
- 医生面诊 PC/H5 的列表及详情显示“面诊类型”;聊天窗口传递并核对本次挂号 ID,避免同一诊单多条挂号类型串用。
|
||||||
|
- 图文问诊保留文本、图片、文件沟通,隐藏语音、视频及群视频入口。直接调用群视频方法或 SDK `call/calls/groupCall` 也会校验;发起前重新读取服务端类型,拒绝已改为图文的挂号。
|
||||||
|
- 服务端签名响应携带本次挂号类型及通话权限;发起通话接口在创建通话记录前再次校验。签名仍供图文聊天使用,未禁用聊天登录。
|
||||||
|
- 视频二维码及旁观入口仅供视频问诊使用,普通诊单二维码仍支持图文问诊。
|
||||||
|
- 多条有效挂号未指定 ID 时允许获取聊天签名,但不猜测可视频的挂号;明确挂号 ID 必须属于当前诊单。历史空类型仅在挂号确实存在时沿用原视频默认值。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `node --test admin/tests/appointment-call-mode.test.cjs`:7 项行为回归通过,包含实际 SFC 脚本执行及真实聊天模板渲染,校验图文无通话按钮、视频仍有按钮、直调拦截、服务端类型变化及跨窗口状态切换。
|
||||||
|
- `node admin/scripts/verify-appointment-type.cjs`:9 个 Vue 组件脚本、模板、样式编译通过,3 个聊天入口传递挂号 ID 的约束通过。
|
||||||
|
- `php server/tests/AppointmentCallPolicyTest.php`:34 个断言通过,内存模型替身,验证挂号选择、图文限制、跨诊单 ID 拒绝及旧数据兼容。
|
||||||
|
- `php server/tests/AppointmentTypeTest.php`、`php server/tests/CallSignatureIdentityContractTest.php` 通过;修改 PHP 语法检查通过。
|
||||||
|
- 全量 `vue-tsc` 已执行,仍有原有模块报错;本次新增工具和聊天组件无报错。编辑过的两个列表仅在原有 `@command` 回调出现已有 `cmd` 隐式 any,未在本次扩大修复范围。输出见 `typecheck.log`。
|
||||||
|
- `git diff --check` 通过。
|
||||||
|
- 全量生产构建通过(1 分 53 秒),输出到任务临时目录,未覆盖站点发布目录;完整输出见 `build.log`,仍有既有的大分包警告。
|
||||||
|
|
||||||
|
分项记录:[页面](ui.md)、[患者列表后端](backend.md)、[服务端通话策略](call-policy.md)。测试使用虚构数据,不连接业务数据库,不生成真实二维码,不登录腾讯云或拨打实际通话。未进行完整浏览器/真实租户联调。
|
||||||
|
|
||||||
|
## 上线
|
||||||
|
|
||||||
|
本次未部署,无新增表字段或 SQL 迁移。需将前后端一起上线:新页面以签名接口返回的通话权限为准,旧后端不返回确认字段时不会开放通话按钮。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 挂号类型展示:后端交付
|
||||||
|
|
||||||
|
按用户澄清,仅补已有问诊类型的展示;未新增线下面诊选项。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
- `server/app/adminapi/lists/firstvisit/MyPatientLists.php`:查询主挂号的 `appointment_type`,并从同一条主挂号返回 `appointment_id`、医生、时间、`appointment_type` 与 `appointment_type_desc`。沿用当前日期和状态筛选的主挂号选择逻辑,避免把最新一条挂号类型拼到另一条挂号上。
|
||||||
|
- 无挂号时返回 `appointment_type = null`、`appointment_type_desc = ''`,避免误示为视频挂号。已有挂号的历史空值仍按原枚举规则显示视频问诊。
|
||||||
|
- `server/app/adminapi/lists/firstvisit/MyPatientProgressLists.php`:类型和标签统一使用 `AppointmentTypeEnum`。历史 `phone` 显示电话问诊,未知类型显示未知,不再错误回退为面诊。
|
||||||
|
- `server/tests/AppointmentTypeTest.php`:补主挂号选择与类型一致性、日期/状态切换、无挂号、历史空值/电话/未知标签的回归覆盖;确认 `offline` 仍不允许写入。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `php server/tests/AppointmentTypeTest.php`:通过。
|
||||||
|
- 上述两个列表文件及测试文件的 `php -l`:通过。
|
||||||
|
- `git diff --check`:通过。
|
||||||
|
- 已确认枚举、创建/编辑验证逻辑及 SQL 文件均无本次变更。
|
||||||
|
|
||||||
|
测试未连接业务数据库、未执行 SQL 或迁移、未发出外部请求。视频通话限制由主代理另行实现和验证。
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
vite v6.4.2 building for production...
|
||||||
|
|
||||||
|
WARN
|
||||||
|
(!) outDir D:\web\zyt\artifacts\appointment-modes\admin-build is not inside project root and will not be emptied.
|
||||||
|
Use --emptyOutDir to override.
|
||||||
|
|
||||||
|
|
||||||
|
transforming...
|
||||||
|
✓ 4061 modules transformed.
|
||||||
|
|
||||||
|
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
||||||
|
|
||||||
|
rendering chunks...
|
||||||
|
computing gzip size...
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DONCMk_U.css 8.04 kB │ gzip: 2.32 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/code-preview-DebWNacj.js 0.16 kB │ gzip: 0.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-B5lAmlRD.js 0.18 kB │ gzip: 0.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/refund-log-Dte4TYSk.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/account-adjust-B00EjgEk.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-o13C0dUX.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-Bw34X5yl.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-fJdDXmQk.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-ZSwm2OnL.js 0.19 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/perm-CxGp7d9E.js 0.20 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog-DhGQ2qc5.js 0.20 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/weapp-AW0lTawR.js 0.20 kB │ gzip: 0.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton-BUP3IK_K.js 0.21 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-n8Jap8KU.js 0.21 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-DobNH3vu.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-B6XQI8aF.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-DyTWIq8-.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-DcP285OM.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CpN5NTKb.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CscKSbQv.js 0.21 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-DpRW2C_C.js 0.22 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-D9QQ1l2O.js 0.23 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/auth-C5D7rPvf.js 0.24 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CBO2zICf.js 0.25 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/relations-add-D1VlWUUa.js 0.25 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mobile-style-DwsiSMiK.js 0.26 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CHvGoI1A.js 0.27 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/cost-edit-Bg-Uyd2c.js 0.30 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yeji-edit-C9uvAsWe.js 0.30 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/404-3rkYd7tU.js 0.31 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/data-table-SmfBi9Sg.js 0.31 kB │ gzip: 0.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit-C7hYj3ER.js 0.33 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/medicine-Dd2rx3Ot.js 0.35 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/consumer-B3GKjsdE.js 0.35 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/user-Ca4bJD6d.js 0.37 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/role-DTJVNPTP.js 0.39 kB │ gzip: 0.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useDictOptions-Dn9M7zwp.js 0.42 kB │ gzip: 0.32 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BzeCW0Uz.js 0.42 kB │ gzip: 0.32 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/admin-BOSvnXa7.js 0.42 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-s8eO9hBN.js 0.44 kB │ gzip: 0.36 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pay-CYh6c1Jc.js 0.45 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/department-jIolW8HD.js 0.46 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-DxVtimm9.js 0.46 kB │ gzip: 0.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-COxSBtU5.js 0.47 kB │ gzip: 0.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-C-UmNM7-.js 0.48 kB │ gzip: 0.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/post-BM66vrTr.js 0.49 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/overflow-CvDxI8pV.js 0.49 kB │ gzip: 0.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/decoration-DcKIqlQW.js 0.50 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/message-NjmKbrE0.js 0.50 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/403-Bd7P9v6E.js 0.52 kB │ gzip: 0.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CwusgJ4Z.js 0.53 kB │ gzip: 0.38 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-C-oRPuSk.js 0.54 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-CY72vprk.js 0.54 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/add-nav-BdWkHCFM.js 0.54 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-DnglhtO0.js 0.54 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-set-O2cSQVkP.js 0.54 kB │ gzip: 0.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-D3WqzAia.js 0.55 kB │ gzip: 0.26 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/patient-vjBAK2HA.js 0.56 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/fans-eQIJEPPO.js 0.60 kB │ gzip: 0.21 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-DZ31aYPg.js 0.60 kB │ gzip: 0.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-lqO8LPXd.js 0.61 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-LZa4samX.js 0.61 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-0Lb4ZZ78.js 0.61 kB │ gzip: 0.41 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C9CTu6xY.js 0.63 kB │ gzip: 0.43 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CFowjcfc.js 0.63 kB │ gzip: 0.32 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/link-DGRRT5eg.js 0.64 kB │ gzip: 0.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/asset-D92fftiF.js 0.67 kB │ gzip: 0.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Bt_MGGD6.js 0.69 kB │ gzip: 0.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useMediaSourceOptions-B0L3IlgH.js 0.70 kB │ gzip: 0.42 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-4NVOwZND.js 0.70 kB │ gzip: 0.45 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/website-CBS8EOwn.js 0.74 kB │ gzip: 0.24 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-C47G34h8.js 0.76 kB │ gzip: 0.49 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/self_input_stats-BMoDYPed.js 0.79 kB │ gzip: 0.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/decoration-img-ChFP1uE8.js 0.81 kB │ gzip: 0.50 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/dict-DqKNj29A.js 0.81 kB │ gzip: 0.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/code-D879PDoD.js 0.82 kB │ gzip: 0.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-NopGY6qR.js 0.84 kB │ gzip: 0.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-1ur9wwmZ.js 0.86 kB │ gzip: 0.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/error-Bn-Kyayl.js 0.89 kB │ gzip: 0.60 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/theme-picker-BiL-qD2M.js 0.92 kB │ gzip: 0.59 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/wx_oa-B5L52qTa.js 1.04 kB │ gzip: 0.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/article-hs8A9Jdu.js 1.07 kB │ gzip: 0.26 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-ClYX-hV1.js 1.08 kB │ gzip: 0.61 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/rich_text-Dk0jS3Pc.js 1.10 kB │ gzip: 0.58 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/cache-CWFtbfTm.js 1.13 kB │ gzip: 0.69 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DCmzItce.js 1.21 kB │ gzip: 0.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/finance-DA6_SyDw.js 1.30 kB │ gzip: 0.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pc-DQbWYqv7.js 1.31 kB │ gzip: 0.78 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/doctor-DyZGuUZC.js 1.32 kB │ gzip: 0.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C21u_UoH.js 1.33 kB │ gzip: 0.78 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-BAht1aZ2.js 1.33 kB │ gzip: 0.71 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-CeY2TUv-.js 1.37 kB │ gzip: 0.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-BJGtt-xN.js 1.38 kB │ gzip: 0.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/file-BTnyaqZX.js 1.41 kB │ gzip: 0.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/upload-DHrBQCOO.js 1.41 kB │ gzip: 0.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content-NSr7rO7a.js 1.42 kB │ gzip: 0.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-C4bf87lU.js 1.44 kB │ gzip: 0.85 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DLNXfvlb.js 1.44 kB │ gzip: 0.85 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_style_index_0_lang-mxcCSl8u.js 1.46 kB │ gzip: 0.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-phone-DVZEALSU.js 1.47 kB │ gzip: 0.80 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-BePIgx8X.js 1.50 kB │ gzip: 0.76 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-BxpNhh7J.js 1.55 kB │ gzip: 0.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-oWJA6Uib.js 1.60 kB │ gzip: 0.82 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BACvjW0Z.js 1.62 kB │ gzip: 0.71 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/style-DHri-b9V.js 1.62 kB │ gzip: 0.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-C9XoZuna.js 1.66 kB │ gzip: 0.87 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/statistics-B3M41WAU.js 1.69 kB │ gzip: 1.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DNKk0KAC.js 1.71 kB │ gzip: 0.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/order-D2TrFdV8.js 1.74 kB │ gzip: 0.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/environment-C74pQttO.js 1.74 kB │ gzip: 0.74 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/setup-Dz_APrpo.js 1.77 kB │ gzip: 1.00 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useMenuOa-DFdWMJUR.js 1.82 kB │ gzip: 0.85 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-setting-D_7bJ_WP.js 1.88 kB │ gzip: 0.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BzDASedl.js 1.90 kB │ gzip: 0.99 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-E0vpVDJX.js 1.94 kB │ gzip: 1.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B9qvdXe9.js 2.03 kB │ gzip: 0.98 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B3x_V6St.js 2.04 kB │ gzip: 1.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B7ZehsXP.js 2.08 kB │ gzip: 1.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DMVXI7dX.js 2.09 kB │ gzip: 1.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CFyAuY4i.js 2.09 kB │ gzip: 0.95 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/open_setting-521tiVc_.js 2.10 kB │ gzip: 1.12 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-0BnrgRc3.js 2.12 kB │ gzip: 1.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CpPfqdLb.js 2.16 kB │ gzip: 1.12 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/filing-Cqmunz7R.js 2.17 kB │ gzip: 1.17 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-ClW9LZFF.js 2.18 kB │ gzip: 1.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/icon-qQvuPtJo.js 2.23 kB │ gzip: 0.82 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Dur4SMQq.js 2.24 kB │ gzip: 1.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-vpDrHDku.js 2.28 kB │ gzip: 1.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C366kQIF.js 2.29 kB │ gzip: 1.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-BOTDmjKh.js 2.40 kB │ gzip: 1.19 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CWND1gXs.js 2.40 kB │ gzip: 1.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/stats-BZP8tLtW.js 2.45 kB │ gzip: 0.58 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-DS49GzuV.js 2.51 kB │ gzip: 1.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-attr-BzWXYYXP.js 2.51 kB │ gzip: 1.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dhx2yo7b.js 2.52 kB │ gzip: 1.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/h5-p3Zf_YQP.js 2.56 kB │ gzip: 1.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/change-password-YIODZgIx.js 2.57 kB │ gzip: 1.35 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-DMG92gvp.js 2.61 kB │ gzip: 1.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tabbar-C4RTjgc5.js 2.66 kB │ gzip: 1.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/file-CT3DBwWs.js 2.73 kB │ gzip: 1.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-uPRl_lL4.js 2.74 kB │ gzip: 1.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-VQL80FaN.js 2.77 kB │ gzip: 1.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-BeAQ08_1.js 2.77 kB │ gzip: 1.08 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-LSZveKRb.js 2.80 kB │ gzip: 1.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CkZpjrdt.js 2.81 kB │ gzip: 1.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C659Qo0h.js 2.85 kB │ gzip: 1.34 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/follow_reply-DVMErqzs.js 2.85 kB │ gzip: 1.49 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-D1k3lHzy.js 2.86 kB │ gzip: 1.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-CVc0n4pV.js 2.86 kB │ gzip: 1.35 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-lmr2i5Bj.js 2.89 kB │ gzip: 1.41 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/default_reply-CpyjKv3V.js 2.91 kB │ gzip: 1.54 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-gdH1LeQj.js 2.96 kB │ gzip: 1.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/protocol-D4wxifpO.js 2.98 kB │ gzip: 1.10 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-PJQQwYnV.js 2.99 kB │ gzip: 1.45 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-Drt73fzr.js 3.03 kB │ gzip: 1.25 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-C8d76FGV.js 3.04 kB │ gzip: 1.26 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BIYyttaB.js 3.05 kB │ gzip: 1.50 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-D2r9RPoC.js 3.06 kB │ gzip: 1.48 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/keyword_reply-DBgqHDjZ.js 3.11 kB │ gzip: 1.60 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DweAr7Ym.js 3.12 kB │ gzip: 1.52 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BUsM_LI5.js 3.21 kB │ gzip: 1.52 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-CnIVKgZ-.js 3.25 kB │ gzip: 1.47 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-BRrEuHV-.js 3.26 kB │ gzip: 1.40 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/pc_details-B_6wZRHo.js 3.39 kB │ gzip: 1.49 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DqGrHNcO.js 3.39 kB │ gzip: 1.45 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-phcPclpA.js 3.42 kB │ gzip: 1.53 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CUge-qSO.js 3.43 kB │ gzip: 1.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BkwnyzI2.js 3.44 kB │ gzip: 1.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/balance_details-Ds1LzmaH.js 3.48 kB │ gzip: 1.63 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DtdgFl7O.js 3.48 kB │ gzip: 1.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-cQgN4IT9.js 3.52 kB │ gzip: 1.56 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/journal-CY6ZpS6p.js 3.73 kB │ gzip: 1.46 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-DKvnMaof.js 3.73 kB │ gzip: 1.57 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/detail-BV7O2HIL.js 3.76 kB │ gzip: 1.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BuTdyKef.js 3.78 kB │ gzip: 1.85 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-BSTysfqz.js 3.78 kB │ gzip: 1.77 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-bI1lUcbW.js 3.91 kB │ gzip: 1.71 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/preview-pc-BY7HFTA8.js 3.98 kB │ gzip: 1.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-BbOdj1-7.js 4.02 kB │ gzip: 1.73 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C8TwWqpc.js 4.11 kB │ gzip: 1.65 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CxWRNLqZ.js 4.11 kB │ gzip: 1.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/first_visit-CvA69-XM.js 4.13 kB │ gzip: 0.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/detail-BRablUBf.js 4.15 kB │ gzip: 1.56 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/MessageBubble-DgY38P4R.js 4.17 kB │ gzip: 1.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bh5w94E3.js 4.22 kB │ gzip: 1.92 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-COfv9A2s.js 4.26 kB │ gzip: 1.52 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B2NSVhqr.js 4.37 kB │ gzip: 2.14 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-DP4Gin1a.js 4.40 kB │ gzip: 2.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/recharge_record-BzIkkNm9.js 4.43 kB │ gzip: 1.87 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B4J-ZHfW.js 4.45 kB │ gzip: 1.79 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BwbCBYM9.js 4.47 kB │ gzip: 1.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-exKfRfkT.js 4.49 kB │ gzip: 1.96 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Bs0vH486.js 4.49 kB │ gzip: 1.96 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/preview-Dh1zljh0.js 4.49 kB │ gzip: 1.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CvCnQtvN.js 4.51 kB │ gzip: 1.75 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-JNdaKBs5.js 4.53 kB │ gzip: 2.26 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-6cheoyXp.js 4.54 kB │ gzip: 2.40 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BLgjrOkJ.js 4.55 kB │ gzip: 1.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/paiban-idrEqd0C.js 4.56 kB │ gzip: 2.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DAvdgv_l.js 4.57 kB │ gzip: 1.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/login_register-BcBecvF5.js 4.59 kB │ gzip: 2.01 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/dept-tongji-CwUw39uH.js 4.66 kB │ gzip: 2.04 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C0B1ZpBc.js 4.76 kB │ gzip: 2.05 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dozjlzsq.js 4.92 kB │ gzip: 2.04 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-BBC7JIUD.js 5.04 kB │ gzip: 1.99 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CeStvPlb.js 5.18 kB │ gzip: 2.03 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-CwUhJWnn.js 5.28 kB │ gzip: 2.43 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-gh639-1m.js 5.32 kB │ gzip: 2.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-rDSQ6cWW.js 5.53 kB │ gzip: 2.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-CfJ7Ipck.js 5.55 kB │ gzip: 2.56 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Dw9aZRHs.js 5.56 kB │ gzip: 1.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BGsSkFG1.js 5.59 kB │ gzip: 1.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-oXdkNbAH.js 5.62 kB │ gzip: 2.15 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/attr-853P--U5.js 5.62 kB │ gzip: 2.24 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tongji-BTDH_24t.js 5.66 kB │ gzip: 2.31 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-Cy0KYlPt.js 5.82 kB │ gzip: 2.84 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DietRecordList-BeSIO_4H.js 5.84 kB │ gzip: 2.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-C1gPiZEq.js 5.91 kB │ gzip: 2.38 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/login-DJgbVJrh.js 6.03 kB │ gzip: 2.72 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/refund_record-MA_WT5dD.js 6.27 kB │ gzip: 2.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-CIc3FdV9.js 6.34 kB │ gzip: 2.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-D4UoI3lC.js 6.35 kB │ gzip: 3.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/information-BZXrACCh.js 6.36 kB │ gzip: 1.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-B64KEfoD.js 6.48 kB │ gzip: 2.60 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/account_cost-DdPYY7oD.js 6.56 kB │ gzip: 2.86 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-QLnHkNyv.js 6.61 kB │ gzip: 2.34 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-hHC-HY2w.js 6.61 kB │ gzip: 2.62 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-6aflqrhb.js 6.77 kB │ gzip: 2.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/readonly-Bc7S11g_.js 6.98 kB │ gzip: 2.69 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/picker-BA72xnJ0.js 7.32 kB │ gzip: 3.18 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKaLV_7V.js 7.35 kB │ gzip: 2.66 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/setting-DK-S6SHN.js 7.35 kB │ gzip: 2.93 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/weapp-BaIevkiG.js 7.39 kB │ gzip: 2.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-RSmge3tZ.js 7.65 kB │ gzip: 3.13 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/config-h4uBlWvN.js 7.77 kB │ gzip: 2.59 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-Bq97RGBJ.js 7.84 kB │ gzip: 3.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/patient-call-CzyPR-gp.js 7.85 kB │ gzip: 3.24 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tcm-DVMUVofm.js 8.99 kB │ gzip: 1.57 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-vUD1ND3r.js 9.00 kB │ gzip: 2.83 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BsQ-IFkz.js 9.49 kB │ gzip: 3.84 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-lzEwKV5j.js 9.58 kB │ gzip: 3.33 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/medicine-D_iyqZcd.js 9.74 kB │ gzip: 3.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-Chzff2sc.js 9.99 kB │ gzip: 3.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CUd56fl0.js 10.06 kB │ gzip: 3.98 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DfXvRPp3.js 10.33 kB │ gzip: 3.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-DD3meqcJ.js 10.36 kB │ gzip: 4.29 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/SendPanel-DDd8ec_S.js 10.37 kB │ gzip: 3.57 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/progress-DuXbNWsm.js 11.18 kB │ gzip: 4.59 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list-Cw7NQEcD.js 11.18 kB │ gzip: 4.03 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DGvtXd9k.js 11.30 kB │ gzip: 4.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/appointment-Cm3Hfa5N.js 11.34 kB │ gzip: 4.24 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-BDOteF3G.js 11.44 kB │ gzip: 3.76 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/OrderPanel-Blnuj7Qu.js 11.74 kB │ gzip: 4.22 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/picker-I1e_QZKi.js 12.15 kB │ gzip: 4.49 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-2qbsbc6G.js 12.58 kB │ gzip: 4.48 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-Dql8RbMo.js 12.76 kB │ gzip: 3.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CcMovsjP.js 13.00 kB │ gzip: 4.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-B_Kh6a1u.js 13.11 kB │ gzip: 3.91 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/h5-BpUP9yLV.js 13.83 kB │ gzip: 4.40 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CWnyJh4D.js 14.26 kB │ gzip: 5.90 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/guahao-DEn7doZO.js 15.06 kB │ gzip: 4.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-CUAXb84X.js 15.22 kB │ gzip: 6.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BrOS-AAf.js 15.62 kB │ gzip: 6.44 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BRhh-vvk.js 15.90 kB │ gzip: 5.20 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-b7xryZ1R.js 15.90 kB │ gzip: 4.70 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CMnX8dfi.js 16.34 kB │ gzip: 6.06 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-NRre3yrk.js 16.35 kB │ gzip: 5.11 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CRhDpVNm.js 16.60 kB │ gzip: 6.07 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-DAmQQSVZ.js 17.15 kB │ gzip: 6.46 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DQb8OBRa.js 17.84 kB │ gzip: 6.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/roster-BVq4roST.js 18.09 kB │ gzip: 5.71 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DXdrxgPI.js 18.19 kB │ gzip: 6.88 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-kf-hJGbx.js 18.23 kB │ gzip: 6.37 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CVeeMncf.js 18.60 kB │ gzip: 5.23 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-D6ufstYr.js 19.11 kB │ gzip: 6.95 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-CdrbUm1n.js 21.16 kB │ gzip: 6.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CS6MIRqR.js 21.91 kB │ gzip: 7.87 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/add-C1r7FTAR.js 22.29 kB │ gzip: 5.36 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-BpYt_Hee.js 22.96 kB │ gzip: 8.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-BPhRn5lY.js 24.97 kB │ gzip: 7.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list_h5-cNTYH_iO.js 27.86 kB │ gzip: 9.43 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/qywx-CJq7NVl4.js 30.35 kB │ gzip: 10.01 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/list-DSAjNXOT.js 30.35 kB │ gzip: 9.74 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/commission-settlement-B1m1GnhE.js 35.83 kB │ gzip: 9.75 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/edit-C-_be8Ci.js 35.86 kB │ gzip: 9.02 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CmQWbBsG.js 36.70 kB │ gzip: 11.81 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index_h5-BSNbLohA.js 37.92 kB │ gzip: 11.61 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CnqPWEF7.js 37.98 kB │ gzip: 13.55 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-CAiJ9mjS.js 40.10 kB │ gzip: 11.87 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-BwYuljL9.js 48.77 kB │ gzip: 13.48 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-Am7Jc2zt.js 51.46 kB │ gzip: 15.16 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-QkQBeLjo.js 55.03 kB │ gzip: 15.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-DpMMQbZW.js 69.54 kB │ gzip: 21.09 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/yeji-D8HJelzr.js 87.40 kB │ gzip: 23.27 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C4cvelAi.js 88.42 kB │ gzip: 25.65 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/order_list-D_i5USUp.js 114.89 kB │ gzip: 31.80 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/order_list_h5-B8Us1G-6.js 139.58 kB │ gzip: 36.82 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/index-C1oKTAGC.js 290.22 kB │ gzip: 92.00 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
||||||
|
../artifacts/appointment-modes/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
||||||
|
|
||||||
|
WARN
|
||||||
|
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||||
|
- Using dynamic import() to code-split the application
|
||||||
|
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||||
|
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||||
|
|
||||||
|
✓ built in 1m 53s
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 挂号通话策略
|
||||||
|
|
||||||
|
已新增 `server/app/common/service/AppointmentCallPolicy.php`,未修改问诊方式枚举、DiagnosisLogic、校验器或聊天页面。
|
||||||
|
|
||||||
|
## 集成 API
|
||||||
|
|
||||||
|
```php
|
||||||
|
AppointmentCallPolicy::resolve(int $diagnosisId, int $appointmentId = 0): array
|
||||||
|
```
|
||||||
|
|
||||||
|
返回固定字段:
|
||||||
|
|
||||||
|
- `appointment_id`: int
|
||||||
|
- `appointment_type`: string|null
|
||||||
|
- `appointment_type_desc`: string
|
||||||
|
- `can_video_call`: bool
|
||||||
|
- `can_audio_call`: bool
|
||||||
|
- `call_disabled_reason`: string
|
||||||
|
|
||||||
|
数据库查询固定用 `Appointment::where('patient_id', $diagnosisId)`;挂号表的 patient_id 是诊单 ID,不是患者来源表 ID。显式挂号不在当前诊单的查询结果内时抛出 `RuntimeException('指定挂号不存在或不属于当前诊单')`,不会回退猜测另一条挂号。
|
||||||
|
|
||||||
|
未指定挂号时:一条 status=1 自动采用;多条 status=1 返回 id=0、type=null、禁止音视频、原因 `请指定本次挂号`。没有有效预约时,在 status=3/4 历史记录中按预约日期、时间、ID 降序选择一条;不拿取消记录作为历史回退。没有可选记录返回 id=0、type=null、描述 `未挂号`、禁止音视频。
|
||||||
|
|
||||||
|
沿用已有 `AppointmentTypeEnum::normalizeStored`:确实存在但类型为空的历史挂号兼容 video;缺少挂号不会凭空默认 video。video 可以音视频,历史 phone 只可以音频,text 及其他类型均禁止音视频。status=2 保留准确类型但禁止通话;未知状态同样禁止。没有新增 offline 或其他枚举。
|
||||||
|
|
||||||
|
## 无数据库验证
|
||||||
|
|
||||||
|
另提供 `resolveFromAppointments(array $appointments, int $appointmentId = 0)` 与 `policyForAppointment(?array $appointment)` 纯函数入口。前者的数组必须已限制为当前诊单的挂号。
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
php -l server/app/common/service/AppointmentCallPolicy.php
|
||||||
|
php server/tests/AppointmentCallPolicyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
语法检查通过;34 项断言通过。测试仅加载策略和枚举,使用内存模型替身验证实际 resolve 的诊单过滤与伪造挂号 ID,完全不启动数据库或网络连接。覆盖图文、视频、历史电话、空类型、未知类型、取消状态、多条有效挂号、明确选择、按日期/时间/ID 选择历史记录和无挂号。
|
||||||
|
|
||||||
|
`git diff --check` 通过。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||||
|
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
|
||||||
|
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
|
||||||
|
src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||||
|
src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||||
|
src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||||
|
src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||||
|
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
|
||||||
|
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||||
|
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
|
||||||
|
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||||
|
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||||
|
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||||
|
Type 'string' is not assignable to type 'number'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||||
|
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||||
|
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||||
|
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||||
|
Type 'string' is not assignable to type 'number'.
|
||||||
|
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||||
|
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||||
|
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||||
|
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||||
|
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
|
||||||
|
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
|
||||||
|
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
|
||||||
|
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
|
||||||
|
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||||
|
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||||
|
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
|
||||||
|
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||||
|
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||||
|
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||||
|
Type 'unknown' is not assignable to type 'number'.
|
||||||
|
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||||
|
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||||
|
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||||
|
Type 'unknown' is not assignable to type 'number'.
|
||||||
|
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
|
||||||
|
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||||
|
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||||
|
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||||
|
Type 'unknown' is not assignable to type 'number'.
|
||||||
|
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||||
|
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||||
|
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||||
|
Type 'unknown' is not assignable to type 'number'.
|
||||||
|
src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||||
|
src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||||
|
src/views/first_visit/wecom_promotion/index.vue(121,43): error TS7006: Parameter 'checked' implicitly has an 'any' type.
|
||||||
|
src/views/first_visit/wecom_promotion/index.vue(217,226): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||||
|
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||||
|
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||||
|
src/views/tcm/appointment/list.vue(266,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||||
|
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
|
||||||
|
Type 'String' has no call signatures.
|
||||||
|
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
|
||||||
|
Type 'String' has no call signatures.
|
||||||
|
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
|
||||||
|
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
|
||||||
|
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
|
||||||
|
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
|
||||||
|
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
|
||||||
|
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
|
||||||
|
src/views/tcm/diagnosis/index.vue(392,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||||
|
src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||||
|
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Appointment type UI changes
|
||||||
|
|
||||||
|
User clarification applied: this change displays the existing appointment types. It does not add an offline / 线下面诊 option or alter the default video selection.
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- `consumer/prescription/guahao.vue`: explicit 挂号类型 column and edit-field label, using the shared description helper.
|
||||||
|
- `first_visit/my_patients/index.vue`: 挂号类型 column; patients without an appointment show `—`.
|
||||||
|
- Diagnosis desktop and H5: each booking displays its own type. Desktop multi-booking type entries include doctor and time so types remain attributable. The fallback row preserves appointment type.
|
||||||
|
- Appointment desktop and H5: 面诊类型 in the list/card and detail view. Non-video bookings open 图文沟通. Chat signature requests and the chat dialog receive the selected appointment ID.
|
||||||
|
- Shared booking drawer: 挂号类型 label, with the existing video and text options retained.
|
||||||
|
- Video QR and video observation entries reject non-video bookings. Diagnosis selection finds an active video booking for video-only actions, retaining its actual appointment ID, type, doctor, and time. Normal diagnosis QR continues to support any active booking, including text.
|
||||||
|
- H5 type labels have enough width; multi-booking cards wrap type onto its own line.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Vue compiler SFC parse, script compilation, and template compilation passed for all seven edited Vue files.
|
||||||
|
- Executed the actual diagnosis helper declarations extracted via the TypeScript AST for desktop and H5. Both passed: unbooked patient has no video; active text keeps normal diagnosis QR but has no video; legacy null type preserves video compatibility; mixed text / completed video / active video selects the active video appointment and its doctor, ID, and time; phone is not eligible for video.
|
||||||
|
- `git diff --check` passed for the edited UI files.
|
||||||
|
- No live API calls, QR generation requests, or video calls were made. Browser visual verification and integrated checks remain with the parent task.
|
||||||
|
|
||||||
|
## Coordination notes
|
||||||
|
|
||||||
|
- Depends on the parent task's `canAppointmentVideoCall` utility and backend appointment type fields / appointment ID enforcement.
|
||||||
|
- Inspected `DiagnosisLogic::generateMiniProgramQrcode`: login QR explicitly uses `doctor_id` as the scene ID; `appointment_id` is not consumed. Existing QR payload semantics are preserved. The selected video booking supplies the correct doctor.
|
||||||
|
- `.trellis/` is absent in this checkout, as noted by the parent task. No subagents were spawned.
|
||||||
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)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 黄果短剧启动闪退诊断
|
||||||
|
|
||||||
|
检查时间:2026-09-09,Asia/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.014,PID 30459 | 15:49:40.866,PID 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.0,Android 15 / API 35,系统 x86_64;ABI 列表为 `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`:6–7、182–197 行。
|
||||||
|
- 当前实例 `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 映射错误(1335–1367 行),但之后 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`;27–30 行 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`:191–198 行记录 15:11:04 安装、15:11:06 启动、15:11:07 退出和崩溃;199–204 行记录 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,但实际可读取内容,因此不能用其长度判断日志是否为空。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 或前端。本子任务未提交。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user