i
This commit is contained in:
gr
2026-10-22 11:12:29 +08:00
480 changed files with 9840 additions and 953 deletions
+8
View File
@@ -5,6 +5,14 @@ export function getConfig() {
return request.get({ url: '/config/getConfig' })
}
// 运行时读取医生网页面诊切换时间,与服务端拦截规则共用配置。
export function getDoctorWebConsultationPolicy() {
return request.get(
{ url: '/config/doctorWebConsultation', params: { _t: Date.now() } },
{ withToken: false, isOpenRetry: false }
)
}
// 工作台主页
export function getWorkbench() {
return request.get({ url: '/workbench/index' })
@@ -24,6 +24,24 @@ export type DesktopWorkstationConfig = {
}
}
export type DesktopWorkstationDownload = DesktopPackage & {
platform: 'windows_x64' | 'macos_arm64' | 'macos_x64'
label: string
}
export type DesktopWorkstationDownloads = {
version: string
packages: DesktopWorkstationDownload[]
}
// 仅返回已发布的安装包,不要求医生拥有系统设置权限。
export function getDesktopWorkstationDownloads() {
return request.get(
{ url: '/setting.desktop_workstation/downloads' },
{ withToken: false }
) as Promise<DesktopWorkstationDownloads>
}
export function getDesktopWorkstationConfig() {
return request.get({
url: '/setting.desktop_workstation/getConfig'
+23
View File
@@ -227,6 +227,29 @@ export function getCallRecords(params: any) {
return request.get({ url: '/tcm.diagnosis/getCallRecords', params })
}
export interface ConsultationCallRecord {
id: number
diagnosis_id: number
call_type?: number
status?: number
start_time?: number
start_time_text?: string
end_time_text?: string
duration_text?: string
transcript_text?: string | null
transcription_status?: string
transcription_status_text?: string
transcription_segment_count?: number
}
/** 按诊单读取每次面诊已保存的文字;不触发语音识别或转写写入。 */
export function getConsultationTranscripts(params: { diagnosis_id: number }, signal?: AbortSignal) {
return request.get<ConsultationCallRecord[]>(
{ url: '/tcm.diagnosis/getCallRecords', params, signal },
{ ignoreCancelToken: true }
)
}
// 将 TRTC 房间号写入通话记录(与云端录制回调 room 关联)
export function bindCallRoom(params: { diagnosis_id: number; room_id: string }) {
return request.post({ url: '/tcm.diagnosis/bindCallRoom', params })
+18 -2
View File
@@ -34,6 +34,7 @@
</div>
</div>
<DoctorWebConsultationNotice v-if="!isMinimized" />
<div v-show="!isMinimized" class="chat-container">
<div v-if="!isReady" class="loading-container">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -131,6 +132,8 @@
<script setup lang="ts">
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
import { addDoctorNote } from '@/api/patient'
@@ -195,8 +198,9 @@ const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointm
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'))
const { webConsultationBlocked, webConsultationMessage, ensureWebConsultationAllowed } = useDoctorWebConsultation()
const canVideoCall = computed(() => !webConsultationBlocked.value && serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => !webConsultationBlocked.value && serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
let releaseAppointmentCallGuard: (() => void) | undefined
let chatContextVersion = 0
let outgoingCallType = 2
@@ -253,6 +257,7 @@ function syncAppointmentCallPolicy(res: {
}
async function prepareAppointmentCall(callType: number) {
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
const contextVersion = chatContextVersion
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
if (!visible.value || !allowed()) {
@@ -266,6 +271,7 @@ async function prepareAppointmentCall(callType: number) {
})
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
syncAppointmentCallPolicy(res)
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
if (!allowed()) {
feedback.msgWarning(callDisabledReason.value)
throw new Error(callDisabledReason.value)
@@ -713,6 +719,14 @@ function installTUICallKitRoomHooks() {
wrap('calls')
wrap('call')
wrap('groupCall')
// 已登录 SDK 的旧聊天窗口也不能在截止后接起新面诊。
const originalAccept = server.accept
if (typeof originalAccept === 'function') {
server.accept = async function patchedAccept(...args: unknown[]) {
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
return originalAccept.apply(server, args)
}
}
}
async function doBindCallRoom(rid: string) {
@@ -1099,6 +1113,7 @@ watch(() => activeConversation.value, async (newConversation) => {
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
if (!ensureWebConsultationAllowed()) return
stopChatArchiveWatch(true)
const contextVersion = ++chatContextVersion
releaseAppointmentCallGuard?.()
@@ -1260,6 +1275,7 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
const startGroupVideoCall = async () => {
if (!ensureWebConsultationAllowed()) return
if (!canVideoCall.value) {
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
return
@@ -0,0 +1,102 @@
<template>
<el-alert
class="doctor-web-consultation-notice"
:type="consultationReminder.type"
:closable="false"
show-icon
:title="consultationReminder.title"
>
<p>{{ consultationReminder.description }}</p>
<p v-if="policyLoadFailed">
面诊切换时间更新失败,请重新获取最新设置。
<el-button link type="primary" @click="refreshConsultationPolicy()">刷新切换时间</el-button>
</p>
<p v-if="consultationReminder.countdown" class="notice-countdown">
距网页端面诊停用还有 {{ consultationReminder.countdown }}(北京时间)
</p>
<div class="notice-downloads">
<a
v-for="item in downloads.packages"
:key="item.platform"
class="installer-link"
:href="item.url"
:download="item.filename || undefined"
target="_blank"
rel="noopener noreferrer"
>
<el-icon><Download /></el-icon>
下载 {{ item.label }}安装包
</a>
<span v-if="downloadsLoading && !downloads.packages.length">正在获取安装包…</span>
<template v-else-if="!downloads.packages.length">
<span>{{ downloadsFailed ? '安装包加载失败,请重试。' : '安装包暂未发布,请联系管理员获取。' }}</span>
<el-button link type="primary" @click="loadDownloads(true)">重新获取</el-button>
</template>
<el-button v-else-if="downloadsFailed" link type="primary" @click="loadDownloads(true)">刷新安装包</el-button>
</div>
<p v-if="downloads.packages.length" class="install-instructions">
<span v-if="downloads.version">当前版本 v{{ downloads.version }} · </span>
安装完成后,使用当前医生账号登录桌面应用。
</p>
<p v-if="hasWindowsInstaller" class="install-instructions">
Windows 安装包适用于 Windows 10 1809 及以上版本、Windows 11(64位)。
</p>
</el-alert>
</template>
<script setup lang="ts">
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { useDesktopWorkstationDownloads } from '@/hooks/useDesktopWorkstationDownloads'
import { Download } from '@element-plus/icons-vue'
import { computed, watch } from 'vue'
const { consultationReminder, policyLoadFailed, refreshConsultationPolicy } = useDoctorWebConsultation()
const { downloads, downloadsLoading, downloadsFailed, loadDownloads } = useDesktopWorkstationDownloads()
const hasWindowsInstaller = computed(() => downloads.value.packages.some(item => item.platform === 'windows_x64'))
watch(() => consultationReminder.value.phase, () => { void loadDownloads(true) })
</script>
<style scoped>
.doctor-web-consultation-notice {
flex-shrink: 0;
margin-bottom: 16px;
}
.doctor-web-consultation-notice p {
margin: 4px 0 0;
font-size: 14px;
line-height: 1.7;
}
.notice-countdown {
font-weight: 600;
}
.notice-downloads {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-top: 12px;
}
.installer-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 6px;
color: #fff;
background: #2563eb;
font-size: 14px;
line-height: 1.5;
text-decoration: none;
}
.installer-link:hover {
background: #1d4ed8;
}
.installer-link:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 3px;
}
.doctor-web-consultation-notice .install-instructions {
margin-top: 8px;
font-size: 13px;
}
</style>
@@ -0,0 +1,47 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import { getConsultationTranscripts, type ConsultationCallRecord } from '@/api/tcm'
import { normalizeConsultationRecords } from '@/utils/consultation-transcripts'
export function useConsultationTranscripts(diagnosisId: () => number) {
const records = ref<ConsultationCallRecord[]>([])
const loading = ref(false)
const error = ref('')
let sequence = 0
let controller: AbortController | undefined
const refresh = async () => {
const id = Number(diagnosisId())
const request = ++sequence
controller?.abort()
error.value = ''
if (!Number.isSafeInteger(id) || id <= 0) {
records.value = []
loading.value = false
return
}
controller = new AbortController()
loading.value = true
try {
const result = await getConsultationTranscripts({ diagnosis_id: id }, controller.signal)
if (request !== sequence || Number(diagnosisId()) !== id) return
records.value = normalizeConsultationRecords(result, id)
} catch (reason: any) {
if (request !== sequence || Number(diagnosisId()) !== id) return
error.value = String(reason?.msg || reason?.message || '面诊文字对话加载失败,请重试')
} finally {
if (request === sequence) loading.value = false
}
}
watch(diagnosisId, () => {
records.value = []
void refresh()
}, { immediate: true, flush: 'sync' })
onBeforeUnmount(() => {
sequence++
controller?.abort()
records.value = []
})
return { records, loading, error, refresh }
}
@@ -0,0 +1,59 @@
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
import {
getDesktopWorkstationDownloads,
type DesktopWorkstationDownload,
type DesktopWorkstationDownloads
} from '@/api/setting/desktop_workstation'
// 接诊台、列表和聊天窗口共用一个请求与短缓存。
const downloads = shallowRef<DesktopWorkstationDownloads>({ version: '', packages: [] })
const downloadsLoading = ref(false)
const downloadsFailed = ref(false)
let pending: Promise<void> | undefined
let loadedAt = 0
const CACHE_MS = 5 * 60 * 1000
export function normalizeDesktopDownloads(value: DesktopWorkstationDownloads): DesktopWorkstationDownloads {
const platforms = ['windows_x64', 'macos_arm64', 'macos_x64']
const packages: DesktopWorkstationDownload[] = []
const seen = new Set<string>()
for (const item of Array.isArray(value?.packages) ? value.packages : []) {
if (!item || typeof item !== 'object') continue
if (!platforms.includes(item.platform) || seen.has(item.platform)) continue
try {
const url = new URL(item.url)
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) continue
if (!/^[a-f\d]{64}$/i.test(item.sha256)) continue
packages.push({ ...item, url: url.href })
seen.add(item.platform)
} catch { /* 配置异常时不生成无效下载链接。 */ }
}
return { version: String(value?.version || ''), packages }
}
export function useDesktopWorkstationDownloads() {
const loadDownloads = (force = false): Promise<void> => {
if (pending) return pending
if (!force && loadedAt > 0 && Date.now() - loadedAt < CACHE_MS) return Promise.resolve()
downloadsLoading.value = true
downloadsFailed.value = false
pending = getDesktopWorkstationDownloads().then(data => {
downloads.value = normalizeDesktopDownloads(data)
loadedAt = Date.now()
}).catch(() => {
downloadsFailed.value = true
}).finally(() => {
downloadsLoading.value = false
pending = undefined
})
return pending
}
const refreshOnFocus = () => { void loadDownloads() }
onMounted(() => {
void loadDownloads()
window.addEventListener('focus', refreshOnFocus)
})
onUnmounted(() => window.removeEventListener('focus', refreshOnFocus))
return { downloads, downloadsLoading, downloadsFailed, loadDownloads }
}
@@ -0,0 +1,88 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { getDoctorWebConsultationPolicy } from '@/api/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
doctorWebConsultationMessage,
doctorWebConsultationReminder,
hasDoctorWebConsultationExpired,
isConsultationDoctor,
isDoctorWebConsultationBlocked,
normalizeDoctorWebConsultationPolicy
} from '@/utils/doctor-web-consultation'
// 多个列表、公告及聊天窗口共用请求;配置更新无需重新构建前端。
let pending: Promise<void> | undefined
let lastAttemptAt = 0
const REFRESH_MS = 60 * 1000
const policyLoadFailed = ref(false)
export function useDoctorWebConsultation() {
const userStore = useUserStore()
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | undefined
const updateClock = () => { now.value = Date.now() + (userStore.serverTimeOffsetMs || 0) }
const policy = computed(() => normalizeDoctorWebConsultationPolicy(userStore.userInfo.web_consultation_policy))
const loadPolicy = (force = false): Promise<void> => {
if (pending) return pending
if (!force && lastAttemptAt > 0 && Date.now() - lastAttemptAt < REFRESH_MS) return Promise.resolve()
lastAttemptAt = Date.now()
pending = getDoctorWebConsultationPolicy().then(data => {
const next = normalizeDoctorWebConsultationPolicy(data)
if (!next) throw new Error('网页面诊时间配置无效')
userStore.userInfo.web_consultation_policy = next
userStore.serverTimeOffsetMs = next.server_time * 1000 - Date.now()
policyLoadFailed.value = false
updateClock()
}).catch(() => {
// 保留最近一次有效配置,后端仍逐次校验实际截止时间。
policyLoadFailed.value = true
}).finally(() => { pending = undefined })
return pending
}
const refreshConsultationPolicy = () => loadPolicy(true)
const refreshOnFocus = () => {
updateClock()
void refreshConsultationPolicy()
}
const refreshOnVisible = () => {
if (document.visibilityState !== 'hidden') refreshOnFocus()
}
// 登录响应和配置刷新响应均提供服务器时刻,避免本机时区/时钟影响规则。
watch(() => userStore.serverTimeOffsetMs, updateClock, { immediate: true })
onMounted(() => {
updateClock()
void refreshConsultationPolicy()
timer = setInterval(() => {
updateClock()
void loadPolicy()
}, 1000)
window.addEventListener('focus', refreshOnFocus)
document.addEventListener('visibilitychange', refreshOnVisible)
})
onUnmounted(() => {
clearInterval(timer)
window.removeEventListener('focus', refreshOnFocus)
document.removeEventListener('visibilitychange', refreshOnVisible)
})
const isDoctor = computed(() => isConsultationDoctor(userStore.userInfo))
const webConsultationExpired = computed(() => hasDoctorWebConsultationExpired(policy.value, now.value))
const consultationReminder = computed(() => doctorWebConsultationReminder(policy.value, now.value))
const webConsultationMessage = computed(() => doctorWebConsultationMessage(policy.value))
const webConsultationBlocked = computed(() => isDoctorWebConsultationBlocked(userStore.userInfo, policy.value, now.value))
const ensureWebConsultationAllowed = () => {
updateClock()
if (!webConsultationBlocked.value) return true
feedback.msgWarning(webConsultationMessage.value)
return false
}
return {
isDoctor, consultationReminder, webConsultationExpired, webConsultationBlocked,
webConsultationMessage, ensureWebConsultationAllowed, policyLoadFailed, refreshConsultationPolicy
}
}
+6 -1
View File
@@ -14,6 +14,7 @@ export interface UserState {
routes: RouteRecordRaw[]
perms: string[]
isPaw: number // 0=需要修改密码,1=正常
serverTimeOffsetMs: number
}
const useUserStore = defineStore({
@@ -27,7 +28,8 @@ const useUserStore = defineStore({
// 权限
perms: [],
// 是否需要修改密码
isPaw: 1
isPaw: 1,
serverTimeOffsetMs: 0
}),
getters: {},
actions: {
@@ -36,6 +38,7 @@ const useUserStore = defineStore({
this.userInfo = {}
this.perms = []
this.isPaw = 1
this.serverTimeOffsetMs = 0
},
login(playload: any) {
const { account, password } = playload
@@ -95,6 +98,8 @@ const useUserStore = defineStore({
getUserInfo()
.then((data) => {
this.userInfo = data.user
const serverTime = Number(data.user?.web_consultation_policy?.server_time)
this.serverTimeOffsetMs = serverTime > 0 ? serverTime * 1000 - Date.now() : 0
this.perms = data.permissions
this.routes = filterAsyncRoutes(data.menu)
// 更新 isPaw 状态
@@ -0,0 +1,45 @@
import type { ConsultationCallRecord } from '@/api/tcm'
export function normalizeConsultationRecords(value: unknown, diagnosisId: number): ConsultationCallRecord[] {
if (!Array.isArray(value)) throw new Error('面诊记录返回格式异常,请重试')
const seen = new Set<number>()
return value.filter(row => {
if (!row || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0 || seen.has(Number(row.id))) return false
if (row.diagnosis_id != null && Number(row.diagnosis_id) !== diagnosisId) return false
seen.add(Number(row.id))
return true
}).map(row => ({
...row,
id: Number(row.id),
diagnosis_id: diagnosisId,
transcript_text: typeof row.transcript_text === 'string' ? row.transcript_text.replace(/\r\n/g, '\n').trim() : ''
})).sort((a, b) => b.id - a.id)
}
export function consultationTranscriptText(row?: ConsultationCallRecord | null): string {
return typeof row?.transcript_text === 'string' ? row.transcript_text.trim() : ''
}
export function consultationTranscriptStatus(row: ConsultationCallRecord) {
switch (row.transcription_status) {
case 'running': return { label: '录音转写中', type: 'info' as const }
case 'completed': return { label: '文字已生成', type: 'success' as const }
case 'partial': return { label: '文字部分保存', type: 'warning' as const }
case 'failed': return { label: '文字生成失败', type: 'danger' as const }
default: return consultationTranscriptText(row)
? { label: '文字已保存', type: 'success' as const }
: { label: '未生成文字', type: 'info' as const }
}
}
export function consultationTranscriptEmptyText(row: ConsultationCallRecord): string {
if (row.transcription_status === 'running') return '正在转写,可点击刷新查看最新保存的文字。'
if (row.transcription_status === 'failed') return '本次面诊文字生成失败,暂无已保存的对话。'
if (Number(row.status) === 3) return '本次通话未接听,暂无文字对话。'
if (Number(row.status) === 4) return '本次通话已取消,暂无文字对话。'
return '本次面诊暂无已保存的文字对话。'
}
export function consultationCallLabel(row: ConsultationCallRecord): string {
return Number(row.call_type) === 1 ? '语音面诊' : Number(row.call_type) === 2 ? '视频面诊' : '面诊记录'
}
@@ -0,0 +1,97 @@
// 所有时间由服务端配置提供,浏览器不保留独立的截止日期。
export type DoctorWebConsultationPolicy = {
cutoff_at: string
approaching_at: string
urgent_at: string
server_time: number
}
export type DoctorWebConsultationPhase = 'prepare' | 'approaching' | 'last_day' | 'expired'
export function normalizeDoctorWebConsultationPolicy(value: unknown): DoctorWebConsultationPolicy | undefined {
if (!value || typeof value !== 'object') return undefined
const policy = value as DoctorWebConsultationPolicy
const timestamps = [policy.approaching_at, policy.urgent_at, policy.cutoff_at].map(date =>
typeof date === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(date) ? Date.parse(date) : NaN
)
if (timestamps.some(time => !Number.isFinite(time)) || timestamps[0] > timestamps[1] || timestamps[1] > timestamps[2]) return undefined
if (!Number.isFinite(policy.server_time) || policy.server_time <= 0) return undefined
return { cutoff_at: policy.cutoff_at, approaching_at: policy.approaching_at, urgent_at: policy.urgent_at, server_time: policy.server_time }
}
export function doctorWebConsultationDate(policy: DoctorWebConsultationPolicy): string {
// 无论医生电脑所在时区为何,公告始终显示北京时间。
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
}).formatToParts(new Date(policy.cutoff_at))
const part = (type: string) => parts.find(item => item.type === type)?.value || ''
const seconds = part('second') === '00' ? '' : `:${part('second')}`
return `${part('year')}年${part('month')}月${part('day')}日${part('hour')}:${part('minute')}${seconds}(北京时间)`
}
export function doctorWebConsultationMessage(policy?: DoctorWebConsultationPolicy): string {
return `网页端面诊已${policy ? `于${doctorWebConsultationDate(policy)}` : ''}停用,请医生使用桌面应用进行面诊。`
}
export function doctorWebConsultationReminder(policy?: DoctorWebConsultationPolicy, now = Date.now()) {
if (!policy) return {
phase: 'prepare' as DoctorWebConsultationPhase,
type: 'info' as const,
title: '系统公告:医生桌面应用安装指引',
description: '请各位医生提前下载并安装桌面应用,使用当前医生账号登录。',
countdown: ''
}
const cutoffDate = doctorWebConsultationDate(policy)
const remaining = Date.parse(policy.cutoff_at) - now
const days = Math.floor(remaining / 86400000)
const hours = Math.floor(remaining / 3600000) % 24
const minutes = Math.floor(remaining / 60000) % 60
const countdown = remaining <= 0 ? '' : remaining < 60000 ? '不足1分钟' :
`${days > 0 ? `${days}天` : ''}${hours > 0 ? `${hours}小时` : ''}${minutes > 0 ? `${minutes}分钟` : ''}`
if (remaining <= 0) return {
phase: 'expired' as DoctorWebConsultationPhase,
type: 'error' as const,
title: '系统公告:医生网页端面诊已停用',
description: doctorWebConsultationMessage(policy),
countdown: ''
}
if (now >= Date.parse(policy.urgent_at)) return {
phase: 'last_day' as DoctorWebConsultationPhase,
type: 'error' as const,
title: '系统公告:医生网页端面诊停用最后提醒',
description: `请各位医生在${cutoffDate}前完成桌面应用的安装与登录。届时接诊台、问诊列表的网页端面诊功能将同步停用,请使用桌面应用进行面诊。`,
countdown
}
if (now >= Date.parse(policy.approaching_at)) return {
phase: 'approaching' as DoctorWebConsultationPhase,
type: 'warning' as const,
title: '系统公告:医生网页端面诊即将停用',
description: `网页端面诊将于${cutoffDate}停用。请各位医生尽快下载桌面应用,完成安装并使用当前医生账号登录,接诊台、问诊列表将同步执行此规则。`,
countdown
}
return {
phase: 'prepare' as DoctorWebConsultationPhase,
type: 'info' as const,
title: '系统公告:请提前安装医生桌面应用',
description: `请各位医生提前下载并安装桌面应用,使用当前医生账号登录试用。自${cutoffDate}起,接诊台、问诊列表的网页端面诊功能将停用,请提前做好准备。`,
countdown: ''
}
}
export function isConsultationDoctor(user: Record<string, any>): boolean {
return Number(user.root) !== 1 && Array.isArray(user.role_ids) && user.role_ids.some(role => Number(role) === 1)
}
export function hasDoctorWebConsultationExpired(policy?: DoctorWebConsultationPolicy, now = Date.now()): boolean {
return !!policy && now >= Date.parse(policy.cutoff_at)
}
export function isDoctorWebConsultationBlocked(
user: Record<string, any>,
policy?: DoctorWebConsultationPolicy,
now = Date.now()
): boolean {
return isConsultationDoctor(user) && hasDoctorWebConsultationExpired(policy, now)
}
+19
View File
@@ -144,6 +144,17 @@
>
<el-table-column type="selection" width="48" />
<el-table-column label="订单号" prop="order_no" min-width="150" />
<el-table-column label="处方业务订单号" min-width="210">
<template #default="{ row }">
<div v-if="row.prescription_orders?.length" class="flex flex-col gap-1">
<span v-for="order in row.prescription_orders" :key="order.id" class="break-all">
{{ order.order_no }}
</span>
</div>
<span v-else class="text-gray-400">未关联</span>
</template>
</el-table-column>
<el-table-column label="患者信息" min-width="150">
<template #default="{ row }">
@@ -302,6 +313,14 @@
<el-tag :type="getStatusTag(detailData.status)">
{{ getStatusText(detailData.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方业务订单号" :span="2">
<div v-if="detailData.prescription_orders?.length" class="flex flex-col gap-1">
<span v-for="order in detailData.prescription_orders" :key="order.id" class="break-all">
{{ order.order_no }}
</span>
</div>
<span v-else class="text-gray-400">未关联</span>
</el-descriptions-item>
<el-descriptions-item label="患者姓名">
{{ detailData.patient?.patient_name || '待关联' }}
+7 -1
View File
@@ -1,5 +1,6 @@
<template>
<div class="reception-page">
<DoctorWebConsultationNotice />
<!-- 页头 -->
<div class="reception-header">
<div class="header-left">
@@ -84,7 +85,7 @@
</button>
</el-tooltip>
<el-tooltip content="发起通话" placement="top">
<button class="row-icon-btn call" @click="handleCall(row)">
<button class="row-icon-btn call" :disabled="webConsultationBlocked" @click="handleCall(row)">
<el-icon><Phone /></el-icon>
</button>
</el-tooltip>
@@ -189,6 +190,8 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
import NoteTimeline from './components/NoteTimeline.vue'
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
@@ -211,6 +214,7 @@ const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/e
const POLL_MS = 5_000
const PAGE_SIZE = 15
const { webConsultationBlocked, ensureWebConsultationAllowed } = useDoctorWebConsultation()
interface QueueRow {
id: number
@@ -438,6 +442,7 @@ const handleSearch = async () => {
}
const handleCall = async (row: QueueRow) => {
if (!ensureWebConsultationAllowed()) return
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
@@ -454,6 +459,7 @@ const handleCall = async (row: QueueRow) => {
appointment_id: Number(row.id),
diagnosis_id: diagnosisId
})
if (!ensureWebConsultationAllowed()) return
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
+2
View File
@@ -1,5 +1,6 @@
<template>
<div class="appointment-list">
<DoctorWebConsultationNotice />
<el-card class="list-card !border-none" shadow="never">
<!-- 单行工具栏:Tab(主) + 搜索(辅) -->
<div class="list-toolbar">
@@ -559,6 +560,7 @@
</template>
<script setup lang="ts">
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
@@ -1,5 +1,6 @@
<template>
<div class="apt-h5">
<DoctorWebConsultationNotice />
<template v-if="currentView === 'list'">
<header class="apt-h5-topbar">
<div class="apt-h5-search">
@@ -412,6 +413,7 @@
</template>
<script setup lang="ts" name="tcmAppointmentH5">
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
import { getWeappConfig } from '@/api/channel/weapp'
@@ -0,0 +1,168 @@
<template>
<section class="consultation-transcripts">
<header class="transcript-toolbar">
<div>
<h3>面诊文字对话</h3>
<p>按每次通话分别查看已保存的文字记录。</p>
</div>
<el-button :loading="loading" @click="refresh"><el-icon><Refresh /></el-icon>刷新</el-button>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon class="transcript-error" />
<div v-loading="loading" class="transcript-body">
<template v-if="records.length">
<aside class="transcript-sessions" aria-label="历次面诊记录">
<el-input v-model="search" clearable placeholder="搜索面诊时间或对话内容" :prefix-icon="Search" />
<p class="session-count">{{ filteredRecords.length }} 条面诊记录</p>
<div class="session-list">
<button
v-for="row in pageRecords"
:key="row.id"
type="button"
class="session-item"
:class="{ selected: selectedId === row.id }"
:aria-pressed="selectedId === row.id"
@click="selectedId = row.id"
>
<span class="session-time">{{ row.start_time_text || '开始时间未记录' }}</span>
<span class="session-meta">{{ consultationCallLabel(row) }} · 记录 #{{ row.id }}</span>
<el-tag size="small" :type="consultationTranscriptStatus(row).type">{{ consultationTranscriptStatus(row).label }}</el-tag>
<span class="session-preview">{{ preview(row) }}</span>
</button>
</div>
<el-pagination
v-if="filteredRecords.length > PAGE_SIZE"
v-model:current-page="page"
:page-size="PAGE_SIZE"
:total="filteredRecords.length"
layout="prev, pager, next"
small
/>
</aside>
<article v-if="selectedRecord" class="transcript-detail" :key="`${diagnosisId}-${selectedRecord.id}`">
<header class="transcript-detail-header">
<div>
<h4>{{ selectedRecord.start_time_text || '开始时间未记录' }} · {{ consultationCallLabel(selectedRecord) }}</h4>
<p>
通话记录 #{{ selectedRecord.id }}
<span v-if="selectedRecord.duration_text"> · 时长 {{ selectedRecord.duration_text }}</span>
<span v-if="selectedRecord.end_time_text"> · 结束 {{ selectedRecord.end_time_text }}</span>
</p>
</div>
<el-button v-if="selectedText" :loading="copying" @click="copyTranscript">复制全文</el-button>
</header>
<div class="transcript-status">
<el-tag :type="consultationTranscriptStatus(selectedRecord).type">{{ consultationTranscriptStatus(selectedRecord).label }}</el-tag>
<span>{{ callStatusText(selectedRecord.status) }}</span>
<span v-if="selectedRecord.transcription_segment_count">{{ selectedRecord.transcription_segment_count }} 段对话</span>
</div>
<p v-if="selectedRecord.transcription_status === 'partial'" class="transcript-note">本次文字记录不完整,以下展示已保存的内容。</p>
<p v-else-if="selectedRecord.transcription_status === 'failed' && selectedText" class="transcript-note">本次转写未完成,以下展示已保存的内容。</p>
<p v-else-if="selectedRecord.transcription_status === 'running' && selectedText" class="transcript-note">转写仍在进行,可刷新查看最新保存的文字。</p>
<pre v-if="selectedText" class="transcript-text">{{ selectedText }}</pre>
<el-empty v-else :description="consultationTranscriptEmptyText(selectedRecord)" :image-size="72" />
</article>
<el-empty v-else description="没有匹配的面诊记录,请调整搜索内容" class="transcript-detail" :image-size="72" />
</template>
<el-empty v-else-if="!loading && !error" description="该诊单暂无面诊通话记录" :image-size="80" class="transcript-empty" />
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import type { ConsultationCallRecord } from '@/api/tcm'
import { useConsultationTranscripts } from '@/hooks/useConsultationTranscripts'
import {
consultationCallLabel,
consultationTranscriptEmptyText,
consultationTranscriptStatus,
consultationTranscriptText
} from '@/utils/consultation-transcripts'
import feedback from '@/utils/feedback'
const props = defineProps<{ diagnosisId: number }>()
const { records, loading, error, refresh } = useConsultationTranscripts(() => props.diagnosisId)
const search = ref('')
const selectedId = ref<number | null>(null)
const page = ref(1)
const copying = ref(false)
const PAGE_SIZE = 10
const filteredRecords = computed(() => {
const term = search.value.trim().toLocaleLowerCase()
return records.value.filter(row => row.diagnosis_id === props.diagnosisId &&
(!term || `${row.start_time_text || ''} ${row.id} ${consultationTranscriptText(row)}`.toLocaleLowerCase().includes(term)))
})
const pageRecords = computed(() => filteredRecords.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
const selectedRecord = computed(() => filteredRecords.value.find(row => row.id === selectedId.value))
const selectedText = computed(() => consultationTranscriptText(selectedRecord.value))
watch(filteredRecords, rows => {
if (!rows.some(row => row.id === selectedId.value)) {
const selected = rows.find(row => consultationTranscriptText(row)) || rows[0]
selectedId.value = selected?.id ?? null
page.value = selected ? Math.floor(rows.indexOf(selected) / PAGE_SIZE) + 1 : 1
}
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / PAGE_SIZE)))
}, { immediate: true, flush: 'sync' })
watch(search, () => { page.value = 1 }, { flush: 'sync' })
function preview(row: ConsultationCallRecord): string {
const text = consultationTranscriptText(row).replace(/\s+/g, ' ')
return text ? text.slice(0, 90) + (text.length > 90 ? '…' : '') : consultationTranscriptEmptyText(row)
}
function callStatusText(value?: number): string {
return ({ 1: '进行中', 2: '已结束', 3: '未接听', 4: '已取消' } as Record<number, string>)[Number(value)] || '状态未记录'
}
async function copyTranscript() {
const text = selectedText.value
if (!text || copying.value) return
if (!navigator.clipboard?.writeText) {
feedback.msgWarning('当前浏览器不支持自动复制,请选中文字复制')
return
}
copying.value = true
try {
await navigator.clipboard.writeText(text)
feedback.msgSuccess('已复制本次面诊文字对话')
} catch {
feedback.msgWarning('复制失败,请选中文字复制')
} finally {
copying.value = false
}
}
</script>
<style scoped>
.consultation-transcripts { padding: 4px 0 16px; }
.transcript-toolbar, .transcript-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.transcript-toolbar { margin-bottom: 20px; }
.transcript-toolbar h3, .transcript-detail h4 { margin: 0; color: var(--el-text-color-primary); font-weight: 600; }
.transcript-toolbar h3 { font-size: 18px; }
.transcript-toolbar p, .transcript-detail-header p { margin: 7px 0 0; color: var(--el-text-color-secondary); line-height: 1.7; font-size: 13px; }
.transcript-error { margin-bottom: 16px; }
.transcript-body { display: flex; min-height: 360px; border-top: 1px solid var(--el-border-color-lighter); }
.transcript-sessions { flex: 0 0 282px; min-width: 0; padding: 18px 18px 0 0; border-right: 1px solid var(--el-border-color-lighter); }
.session-count { margin: 12px 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.session-list { display: flex; flex-direction: column; gap: 8px; max-height: 580px; overflow-y: auto; margin-bottom: 12px; }
.session-item { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; padding: 13px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 7px; background: var(--el-bg-color); text-align: left; cursor: pointer; font: inherit; }
.session-item:hover, .session-item.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.session-item:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
.session-time { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
.session-meta, .session-preview { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
.session-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.transcript-detail { flex: 1; min-width: 0; padding: 20px 0 0 24px; }
.transcript-detail h4 { font-size: 16px; line-height: 1.7; }
.transcript-status { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 16px 0; color: var(--el-text-color-secondary); font-size: 13px; }
.transcript-note { padding: 10px 12px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 13px; line-height: 1.7; }
.transcript-text { margin: 0; padding: 20px 0; color: var(--el-text-color-primary); white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 15px; line-height: 2; user-select: text; }
.transcript-empty { flex: 1; }
@media (max-width: 900px) {
.transcript-body { flex-direction: column; }
.transcript-sessions { flex-basis: auto; padding: 16px 0; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.session-list { max-height: 300px; }
.transcript-detail { padding: 20px 0 0; }
.transcript-detail-header { flex-wrap: wrap; }
}
</style>
+20 -1
View File
@@ -744,6 +744,20 @@
<el-empty v-else description="请先保存诊单后再查看视频录制回放记录" />
</el-tab-pane>
<el-tab-pane
v-if="hasCallTranscriptPermission"
label="面诊文字对话"
name="callTranscript"
:disabled="!formData.id"
lazy
>
<consultation-transcript-panel
v-if="visible && activeTab === 'callTranscript' && formData.id"
:key="Number(formData.id)"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else-if="!formData.id" description="请先保存诊单后再查看面诊文字对话" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/chat'])"
label="聊天记录"
name="chat"
@@ -827,6 +841,7 @@ import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ConsultationTranscriptPanel from './components/ConsultationTranscriptPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
@@ -862,6 +877,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const hasCallTranscriptPermission = computed(() => hasPermission(['tcm.diagnosis/huifang']) || hasPermission(['tcm.diagnosis/getCallRecords']))
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
@@ -930,12 +946,15 @@ watch(() => activeTab.value, async (tab) => {
}
})
watch([visible, activeTab], () => {
watch([visible, activeTab, hasCallTranscriptPermission], () => {
if (!visible.value) return
if (activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'callTranscript' && !hasCallTranscriptPermission.value) {
activeTab.value = 'basic'
}
if (
+2
View File
@@ -1,5 +1,6 @@
<template>
<div class="tcm-diagnosis">
<DoctorWebConsultationNotice />
<el-card class="filter-card !border-none" shadow="never">
<!-- 第一行:日期 + 患者搜索 -->
<div class="filter-main">
@@ -767,6 +768,7 @@
</template>
<script setup lang="ts" name="tcmDiagnosis">
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
import { orderCreate } from '@/api/order'
import { cancelAppointment } from '@/api/doctor'
+34 -2
View File
@@ -26,15 +26,19 @@ 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 = {}) {
function chat(policy = {}, migrationBlocked = vue.ref(false)) {
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 server = Object.fromEntries(['calls', 'call', 'groupCall', 'accept'].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 === '@/hooks/useDoctorWebConsultation') return {
useDoctorWebConsultation: () => ({ webConsultationBlocked: migrationBlocked, webConsultationMessage: vue.ref('网页端面诊已停用,请医生使用桌面应用进行面诊。'), ensureWebConsultationAllowed: () => !migrationBlocked.value })
}
if (name === '@/utils/doctor-web-consultation') return moduleFrom(read('utils/doctor-web-consultation.ts'))
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) } }
@@ -144,3 +148,31 @@ test('singleton call guard switches to current chat and cancels a pending old co
releaseNew()
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/)
})
test('a cached video session stops every outgoing SDK path and incoming accept after migration', async () => {
const blocked = vue.ref(false)
const { state, server, sdkCalls, requests } = chat(policy('video'), blocked)
state.installTUICallKitRoomHooks()
assert.equal(state.canVideoCall.value, true)
blocked.value = true
assert.equal(state.canVideoCall.value, false)
assert.equal(state.canAudioCall.value, false)
for (const method of ['call', 'calls', 'groupCall', 'accept']) {
await assert.rejects(server[method]({ type: 2 }), /桌面应用/)
}
await state.startGroupVideoCall()
assert.deepEqual(sdkCalls, [])
assert.deepEqual(requests, [])
})
test('a signature request spanning the deadline never reaches the calling SDK', async () => {
const blocked = vue.ref(false)
const { state, server, sdkCalls, api } = chat(policy('video'), blocked)
state.installTUICallKitRoomHooks()
api.getCallSignature = async () => {
blocked.value = true
return policy('video')
}
await assert.rejects(server.calls({ type: 2 }), /桌面应用/)
assert.deepEqual(sdkCalls, [])
})
@@ -0,0 +1,214 @@
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 { renderToString } = require('@vue/server-renderer')
const read = name => fs.readFileSync(path.join(__dirname, '../src', name), 'utf8')
function moduleFrom(source, dependencies = require, globals = {}) {
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(dependencies, module, module.exports, ...Object.values(globals))
return module.exports
}
const util = moduleFrom(read('utils/consultation-transcripts.ts'))
const row = (id, diagnosis = 101, text = `医生:记录${id}的问题\n患者:记录${id}的回答`) => ({
id, diagnosis_id: diagnosis, call_type: 2, status: 2, start_time: 1792444800,
start_time_text: '2026-10-20 09:20:00', end_time_text: '2026-10-20 09:30:00',
duration_text: '10分钟', transcript_text: text, transcription_status: 'completed', transcription_segment_count: 2
})
const drain = () => new Promise(resolve => setImmediate(resolve))
test('each call keeps its own transcript even when multiple visits have the same date', () => {
const rows = util.normalizeConsultationRecords([row(10), row(11), row(12, 202, '串诊单文字'), row(10), null, { id: 0 }], 101)
assert.deepEqual(rows.map(item => item.id), [11, 10])
assert.equal(rows[0].transcript_text, row(11).transcript_text)
assert.equal(rows[1].transcript_text, row(10).transcript_text)
assert.throws(() => util.normalizeConsultationRecords({ lists: [] }, 101), /格式异常/)
const legacy = util.normalizeConsultationRecords([{ id: '13', transcript_text: '医生:你好\r\n患者:你好' }], 101)[0]
assert.equal(legacy.diagnosis_id, 101)
assert.equal(legacy.transcript_text, '医生:你好\n患者:你好')
})
test('running, partial and failed calls retain their saved words without pretending they are complete', () => {
for (const [status, label] of [['running', '录音转写中'], ['partial', '文字部分保存'], ['failed', '文字生成失败']]) {
const record = { ...row(1), transcription_status: status }
assert.equal(util.consultationTranscriptStatus(record).label, label)
assert.equal(util.consultationTranscriptText(record), row(1).transcript_text)
}
assert.match(util.consultationTranscriptEmptyText({ ...row(1), transcription_status: 'running', transcript_text: '' }), /正在转写/)
assert.match(util.consultationTranscriptEmptyText({ status: 3 }), /未接听/)
assert.match(util.consultationTranscriptEmptyText({ status: 4 }), /已取消/)
assert.equal(util.consultationTranscriptStatus({ transcript_text: '历史已保存文字' }).label, '文字已保存')
})
function hookFixture(initial = 101) {
const id = vue.ref(initial), requests = [], cleanup = []
const hook = moduleFrom(read('hooks/useConsultationTranscripts.ts'), name => {
if (name === 'vue') return { ...vue, onBeforeUnmount: fn => cleanup.push(fn) }
if (name === '@/api/tcm') return { getConsultationTranscripts: (params, signal) => new Promise((resolve, reject) => requests.push({ params, signal, resolve, reject })) }
if (name === '@/utils/consultation-transcripts') return util
throw new Error(name)
})
const scope = vue.effectScope()
const state = scope.run(() => hook.useConsultationTranscripts(() => id.value))
return { id, requests, state, dispose() { cleanup.forEach(fn => fn()); scope.stop() } }
}
test('changing diagnosis clears the old words immediately and ignores late responses and failures', async () => {
const f = hookFixture()
assert.deepEqual(f.requests[0].params, { diagnosis_id: 101 })
f.id.value = 202
assert.equal(f.requests[0].signal.aborted, true)
assert.deepEqual(f.state.records.value, [])
f.requests[1].resolve([row(20, 202), row(99, 101, '不属于该诊单')])
await drain()
assert.deepEqual(f.state.records.value.map(item => item.id), [20])
f.requests[0].reject(new Error('旧诊单请求失败'))
await drain()
assert.deepEqual(f.state.records.value.map(item => item.id), [20])
assert.equal(f.state.error.value, '')
f.id.value = 303
assert.deepEqual(f.state.records.value, [])
f.dispose()
assert.equal(f.requests[2].signal.aborted, true)
f.requests[2].resolve([row(30, 303)])
await drain()
assert.deepEqual(f.state.records.value, [])
})
test('manual refresh preserves current records on failure and recovers on retry without writes', async () => {
const f = hookFixture()
f.requests[0].resolve([row(10)])
await drain()
const failed = f.state.refresh()
f.requests[1].reject(new Error('网络连接失败'))
await failed
assert.equal(f.state.records.value[0].id, 10)
assert.equal(f.state.error.value, '网络连接失败')
assert.equal(f.state.loading.value, false)
const retried = f.state.refresh()
f.requests[2].resolve([row(11)])
await retried
assert.equal(f.state.error.value, '')
assert.equal(f.state.records.value[0].id, 11)
f.id.value = 0
assert.equal(f.requests.length, 3)
assert.deepEqual(f.state.records.value, [])
f.dispose()
})
const filename = 'views/tcm/diagnosis/components/ConsultationTranscriptPanel.vue'
const { descriptor, errors } = parse(read(filename), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'consultation-transcript-panel' })
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'consultation-transcript-panel', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const render = moduleFrom(template.code).render
function panelFixture(records, extra = {}) {
const props = vue.reactive({ diagnosisId: 101 })
const data = { records: vue.ref(records), loading: vue.ref(false), error: vue.ref(''), refresh() {}, ...extra }
const copied = [], notices = []
const scope = vue.effectScope()
const component = moduleFrom(script.content, name => {
if (name === 'vue') return vue
if (name === '@/hooks/useConsultationTranscripts') return { useConsultationTranscripts: () => data }
if (name === '@/utils/consultation-transcripts') return util
if (name === '@/utils/feedback') return { default: { msgWarning: text => notices.push(text), msgSuccess: text => notices.push(text) } }
if (name === '@element-plus/icons-vue') return { Refresh: { render: () => vue.h('svg') }, Search: {} }
throw new Error(name)
}, { navigator: { clipboard: { writeText: async text => { copied.push(text) } } } })
const state = scope.run(() => component.default.setup(props, { expose() {} }))
async function html() {
const app = vue.createSSRApp({ render: () => render({}, [], props, vue.proxyRefs(state)) })
app.directive('loading', { getSSRProps: () => ({}) })
for (const name of ['el-button', 'el-icon', 'el-pagination']) app.component(name, { setup: (_, { slots }) => () => vue.h('span', slots.default?.()) })
app.component('el-tag', { props: ['type'], setup: (props, { slots }) => () => vue.h('span', { 'data-type': props.type }, slots.default?.()) })
app.component('el-empty', { props: ['description'], setup: props => () => vue.h('p', { class: 'empty-description' }, props.description) })
app.component('el-alert', { props: ['title'], setup: props => () => vue.h('p', { role: 'alert' }, props.title) })
app.component('el-input', { setup: () => () => vue.h('input') })
return renderToString(app)
}
return { props, state, data, copied, notices, html, dispose: () => scope.stop() }
}
test('the real panel selects one call, copies only its complete text, and escapes transcript HTML', async () => {
const f = panelFixture([row(12, 101, ''), row(11, 101, '医生:<img src=x onerror=alert(1)>\n患者:本次回答'), row(10), row(99, 202, '串诊单文字')])
assert.equal(f.state.selectedId.value, 11)
let html = await f.html()
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.doesNotMatch(html, /<img src=x|串诊单文字/)
assert.doesNotMatch(html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/)[1], /记录10/)
await f.state.copyTranscript()
assert.deepEqual(f.copied, ['医生:<img src=x onerror=alert(1)>\n患者:本次回答'])
f.state.selectedId.value = 10
assert.equal(f.state.selectedText.value, row(10).transcript_text)
f.data.records.value = [row(13), row(11), row(10)]
assert.equal(f.state.selectedId.value, 10)
f.state.search.value = '记录13'
assert.equal(f.state.selectedId.value, 13)
assert.equal(f.state.filteredRecords.value.length, 1)
f.props.diagnosisId = 202
assert.equal(f.state.selectedText.value, '')
assert.doesNotMatch(await f.html(), /记录13的问题|本次回答/)
f.dispose()
})
test('empty, failed and partial transcription states remain explicit in the rendered panel', async () => {
for (const status of ['running', 'partial', 'failed']) {
const f = panelFixture([{ ...row(1), transcription_status: status }])
const html = await f.html()
assert.match(html, /已保存|刷新查看最新/)
assert.match(html, /记录1的问题/)
f.dispose()
}
const missing = panelFixture([row(1, 101, '')])
assert.match(await missing.html(), /本次面诊暂无已保存的文字对话/)
missing.dispose()
const none = panelFixture([])
assert.match(await none.html(), /该诊单暂无面诊通话记录/)
none.dispose()
const failed = panelFixture([], { error: vue.ref('无权访问该诊单') })
assert.match(await failed.html(), /无权访问该诊单/)
assert.doesNotMatch(await failed.html(), /该诊单暂无面诊通话记录/)
failed.dispose()
})
test('the new tab is permission gated, lazy and absent when the drawer is closed', async () => {
const { descriptor } = parse(read('views/tcm/diagnosis/edit.vue'))
const editScript = compileScript(descriptor, { id: 'diagnosis-edit-transcript' })
const pane = descriptor.template.content.match(/<el-tab-pane\s+v-if="hasCallTranscriptPermission"[\s\S]*?<\/el-tab-pane>/)[0]
const compiled = compileTemplate({ source: pane, filename: 'edit-transcript-tab.vue', id: 'edit-transcript', compilerOptions: { bindingMetadata: editScript.bindings } })
assert.deepEqual(compiled.errors, [])
const renderPane = moduleFrom(compiled.code).render
async function html(allowed, active, visible, id) {
const state = { hasCallTranscriptPermission: allowed, activeTab: active, visible, formData: { id }, ConsultationTranscriptPanel: { props: ['diagnosisId'], setup: props => () => vue.h('div', { 'data-diagnosis': props.diagnosisId }, '真实转写面板入口') } }
const app = vue.createSSRApp({ render: () => renderPane({}, [], {}, state) })
app.component('el-tab-pane', { props: ['disabled', 'label'], setup: (props, { slots }) => () => vue.h('section', { 'data-disabled': String(props.disabled) }, [props.label, slots.default?.()]) })
app.component('el-empty', { props: ['description'], setup: props => () => vue.h('p', props.description) })
return renderToString(app)
}
assert.doesNotMatch(await html(false, 'callTranscript', true, 101), /面诊文字对话/)
assert.doesNotMatch(await html(true, 'basic', true, 101), /真实转写面板入口/)
assert.doesNotMatch(await html(true, 'callTranscript', false, 101), /真实转写面板入口/)
assert.match(await html(true, 'callTranscript', true, 101), /data-diagnosis="101"/)
assert.match(await html(true, 'callTranscript', true, ''), /data-disabled="true"/)
})
test('the read helper sends the exact diagnosis and abort signal without canceling replay requests', async () => {
let request
const api = moduleFrom(read('api/tcm.ts'), name => {
if (name === '@/utils/request') return { default: { get: (...args) => { request = args; return Promise.resolve([]) } } }
throw new Error(name)
})
const controller = new AbortController()
await api.getConsultationTranscripts({ diagnosis_id: 101 }, controller.signal)
assert.equal(request[0].url, '/tcm.diagnosis/getCallRecords')
assert.deepEqual(request[0].params, { diagnosis_id: 101 })
assert.equal(request[0].signal, controller.signal)
assert.equal(request[1].ignoreCancelToken, true)
})
@@ -0,0 +1,344 @@
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 read = name => fs.readFileSync(path.join(__dirname, '../src', name), 'utf8')
function moduleFrom(source, dependencies = require, globals = {}) {
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(dependencies, module, module.exports, ...Object.values(globals))
return module.exports
}
const policy = moduleFrom(read('utils/doctor-web-consultation.ts'))
const cutoff = Date.parse('2026-10-24T00:00:00+08:00')
const schedule = {
cutoff_at: '2026-10-24T00:00:00+08:00',
approaching_at: '2026-10-22T00:00:00+08:00',
urgent_at: '2026-10-23T00:00:00+08:00',
server_time: cutoff / 1000
}
const doctor = { root: 0, role_ids: [1] }
const installer = {
platform: 'windows_x64', label: 'Windows 64位',
url: 'https://cdn.example.test/doctor-setup.exe',
filename: 'doctor-setup.exe', sha256: 'a'.repeat(64), size: 171474334, type: 'inno_setup'
}
test('doctors can use the web throughout October 23, and stop exactly at Shanghai midnight', () => {
assert.equal(Date.parse(schedule.cutoff_at), Date.parse('2026-10-23T16:00:00Z'))
assert.equal(policy.isDoctorWebConsultationBlocked(doctor, schedule, cutoff - 1), false)
assert.equal(policy.isDoctorWebConsultationBlocked(doctor, schedule, cutoff), true)
assert.equal(policy.isDoctorWebConsultationBlocked(doctor, schedule, cutoff + 1), true)
for (const user of [{ root: 1, role_ids: [1] }, { role_ids: [2] }, { role_ids: [3, 7, 8] }, {}]) {
assert.equal(policy.isDoctorWebConsultationBlocked(user, schedule, cutoff), false)
}
assert.equal(policy.isDoctorWebConsultationBlocked({ role_ids: ['2', '1'] }, schedule, cutoff), true)
})
test('the mounted page changes at midnight, rechecks clicks and keeps a previously synchronized clock', () => {
let clock = cutoff - 10000
const mounted = [], unmounted = [], warnings = []
let tick
const listeners = new Map()
const surface = { addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) }
// The profile was fetched earlier; opening a new page must not reset to that old server timestamp.
const store = vue.reactive({ userInfo: { ...doctor, web_consultation_policy: schedule }, serverTimeOffsetMs: 5000 })
const hook = moduleFrom(read('hooks/useDoctorWebConsultation.ts'), name => {
if (name === 'vue') return { ...vue, onMounted: fn => mounted.push(fn), onUnmounted: fn => unmounted.push(fn) }
if (name === '@/stores/modules/user') return { default: () => store }
if (name === '@/api/app') return { getDoctorWebConsultationPolicy: () => new Promise(() => {}) }
if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } }
if (name === '@/utils/doctor-web-consultation') return policy
throw new Error(`Unexpected import: ${name}`)
}, { Date: { now: () => clock }, window: surface, document: surface, setInterval: fn => { tick = fn; return 1 }, clearInterval: () => { tick = undefined } })
const state = hook.useDoctorWebConsultation()
mounted.forEach(fn => fn())
assert.equal(state.webConsultationBlocked.value, false)
clock = cutoff - 5000
tick()
assert.equal(state.webConsultationBlocked.value, true)
assert.equal(state.ensureWebConsultationAllowed(), false)
assert.match(warnings[0], /桌面应用/)
clock = cutoff - 5001
listeners.get('focus')()
assert.equal(state.webConsultationBlocked.value, false)
clock = cutoff - 5000
// Even if a suspended tab's timer has not fired, the click itself checks the clock.
assert.equal(state.ensureWebConsultationAllowed(), false)
const laterPage = hook.useDoctorWebConsultation()
assert.equal(laterPage.webConsultationBlocked.value, true)
unmounted.forEach(fn => fn())
assert.equal(listeners.size, 0)
assert.equal(tick, undefined)
})
test('the shared system notice keeps installer downloads available throughout all reminder phases', async () => {
const { descriptor } = parse(read('components/doctor-web-consultation-notice/index.vue'))
const script = compileScript(descriptor, { id: 'doctor-web-notice' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'notice.vue', id: 'doctor-web-notice', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const render = moduleFrom(template.code).render
async function markup(date, packages = [installer], failed = false, loading = false) {
const component = moduleFrom(script.content, name => {
if (name === 'vue') return vue
if (name === '@/hooks/useDoctorWebConsultation') return { useDoctorWebConsultation: () => ({ consultationReminder: vue.ref(policy.doctorWebConsultationReminder(schedule, Date.parse(date))), policyLoadFailed: vue.ref(false), refreshConsultationPolicy() {} }) }
if (name === '@/hooks/useDesktopWorkstationDownloads') return {
useDesktopWorkstationDownloads: () => ({ downloads: vue.ref({ version: '1.4.8', packages }), downloadsLoading: vue.ref(loading), downloadsFailed: vue.ref(failed), loadDownloads() {} })
}
if (name === '@element-plus/icons-vue') return { Download: { render: () => vue.h('svg') } }
throw new Error(name)
})
const state = component.default.setup({}, { expose() {} })
const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) })
app.component('el-alert', { props: ['title', 'type', 'closable'], setup: (props, { slots }) => () => vue.h('section', { role: 'alert', 'data-type': props.type, 'data-closable': String(props.closable) }, [vue.h('strong', props.title), slots.default?.()]) })
app.component('el-icon', { setup: (_, { slots }) => () => vue.h('span', slots.default?.()) })
app.component('el-button', { setup: (_, { slots }) => () => vue.h('button', slots.default?.()) })
return renderToString(app)
}
const before = await markup('2026-10-20T10:00:00+08:00')
assert.match(before, /2026年10月24日00:00(北京时间)/)
assert.match(before, /接诊台、问诊列表/)
assert.match(before, /data-closable="false"/)
assert.match(before, /data-type="info"/)
assert.match(before, /href="https:\/\/cdn.example.test\/doctor-setup.exe"/)
assert.match(before, /下载 Windows 64位安装包/)
assert.match(before, /当前版本 v1.4.8/)
assert.match(before, /Windows 10 1809/)
const approaching = await markup('2026-10-22T12:00:00+08:00')
assert.match(approaching, /data-type="warning"/)
assert.match(approaching, /距网页端面诊停用还有 1天12小时/)
const lastDay = await markup('2026-10-23T23:59:59+08:00')
assert.match(lastDay, /最后提醒/)
assert.match(lastDay, /不足1分钟/)
assert.match(lastDay, /data-type="error"/)
const after = await markup('2026-10-24T00:00:00+08:00')
assert.match(after, /网页端面诊已停用/)
assert.match(after, /桌面应用/)
assert.match(after, /data-type="error"/)
assert.match(after, /href="https:\/\/cdn.example.test\/doctor-setup.exe"/)
assert.doesNotMatch(after, /距网页端面诊停用还有/)
const missing = await markup('2026-10-20T10:00:00+08:00', [])
assert.match(missing, /安装包暂未发布/)
assert.match(missing, /重新获取/)
assert.doesNotMatch(missing, /href=/)
assert.match(await markup('2026-10-20T10:00:00+08:00', [], true), /安装包加载失败/)
assert.match(await markup('2026-10-20T10:00:00+08:00', [], false, true), /正在获取安装包/)
})
test('reminders switch exactly at Beijing midnight on October 22, 23 and 24', () => {
for (const [boundary, previous, current] of [
['2026-10-22T00:00:00+08:00', 'prepare', 'approaching'],
['2026-10-23T00:00:00+08:00', 'approaching', 'last_day'],
['2026-10-24T00:00:00+08:00', 'last_day', 'expired']
]) {
const time = Date.parse(boundary)
assert.equal(policy.doctorWebConsultationReminder(schedule, time - 1).phase, previous)
assert.equal(policy.doctorWebConsultationReminder(schedule, time).phase, current)
}
assert.equal(policy.doctorWebConsultationReminder(schedule, Date.parse('2026-10-23T15:59:59Z')).phase, 'last_day')
assert.equal(policy.doctorWebConsultationReminder(schedule, Date.parse('2026-10-23T16:00:00Z')).phase, 'expired')
})
test('an open page advances through each reminder stage without refreshing or changing the cutoff', () => {
let clock = Date.parse('2026-10-21T23:59:59+08:00')
let tick
const mounted = [], unmounted = []
const surface = { addEventListener() {}, removeEventListener() {} }
const hook = moduleFrom(read('hooks/useDoctorWebConsultation.ts'), name => {
if (name === 'vue') return { ...vue, onMounted: fn => mounted.push(fn), onUnmounted: fn => unmounted.push(fn) }
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { ...doctor, web_consultation_policy: schedule }, serverTimeOffsetMs: 0 }) }
if (name === '@/api/app') return { getDoctorWebConsultationPolicy: () => new Promise(() => {}) }
if (name === '@/utils/feedback') return { default: { msgWarning() {} } }
if (name === '@/utils/doctor-web-consultation') return policy
throw new Error(name)
}, { Date: { now: () => clock }, window: surface, document: surface, setInterval: fn => { tick = fn; return 1 }, clearInterval() {} })
const state = hook.useDoctorWebConsultation()
mounted.forEach(fn => fn())
assert.equal(state.consultationReminder.value.phase, 'prepare')
for (const [day, phase] of [[22, 'approaching'], [23, 'last_day'], [24, 'expired']]) {
clock = Date.parse(`2026-10-${day}T00:00:00+08:00`)
tick()
assert.equal(state.consultationReminder.value.phase, phase)
assert.equal(state.webConsultationBlocked.value, day === 24)
}
unmounted.forEach(fn => fn())
})
function shiftedSchedule(cutoffAt, serverTime, approachingHours = 48, urgentHours = 24) {
const time = Date.parse(cutoffAt)
return {
cutoff_at: cutoffAt,
approaching_at: new Date(time - approachingHours * 3600000).toISOString(),
urgent_at: new Date(time - urgentHours * 3600000).toISOString(),
server_time: serverTime / 1000
}
}
test('arbitrary configured dates and hours drive announcements, reminders and enforcement to the second', () => {
const custom = shiftedSchedule('2027-06-15T14:35:12+08:00', cutoff, 10, 2)
const end = Date.parse(custom.cutoff_at)
const approaching = Date.parse(custom.approaching_at)
const urgent = Date.parse(custom.urgent_at)
assert.deepEqual(policy.normalizeDoctorWebConsultationPolicy(custom), custom)
for (const [time, phase] of [[approaching - 1, 'prepare'], [approaching, 'approaching'], [urgent - 1, 'approaching'], [urgent, 'last_day'], [end - 1, 'last_day'], [end, 'expired']]) {
const reminder = policy.doctorWebConsultationReminder(custom, time)
assert.equal(reminder.phase, phase)
assert.match(reminder.description, /2027年06月15日14:35:12(北京时间)/)
assert.doesNotMatch(reminder.description, /2026年|今天结束|最后一天/)
assert.equal(policy.isDoctorWebConsultationBlocked(doctor, custom, time), time >= end)
}
assert.equal(policy.doctorWebConsultationDate({ ...custom, cutoff_at: '2027-06-15T06:35:12Z' }), '2027年06月15日14:35:12(北京时间)')
assert.equal(policy.isDoctorWebConsultationBlocked(doctor, undefined, end), false)
assert.equal(policy.doctorWebConsultationReminder().countdown, '')
for (const invalid of [null, {}, { ...custom, cutoff_at: 'invalid' }, { ...custom, approaching_at: '2027-06-16T00:00:00+08:00' }, { ...custom, server_time: 0 }, { ...custom, urgent_at: '2027-06-15 13:00:00' }]) {
assert.equal(policy.normalizeDoctorWebConsultationPolicy(invalid), undefined)
}
})
test('open notices share live configuration, poll every minute and refresh on focus with failure recovery', async () => {
let clock = Date.parse('2027-06-15T14:00:00+08:00')
let requests = 0, resolveRequest, rejectRequest
const mounted = [], unmounted = [], ticks = new Set(), listeners = new Map()
const surface = {
visibilityState: 'visible',
addEventListener(name, callback) {
if (!listeners.has(name)) listeners.set(name, new Set())
listeners.get(name).add(callback)
},
removeEventListener(name, callback) { listeners.get(name)?.delete(callback) }
}
const store = vue.reactive({ userInfo: { ...doctor, web_consultation_policy: schedule }, serverTimeOffsetMs: 0 })
const hook = moduleFrom(read('hooks/useDoctorWebConsultation.ts'), name => {
if (name === 'vue') return { ...vue, onMounted: fn => mounted.push(fn), onUnmounted: fn => unmounted.push(fn) }
if (name === '@/stores/modules/user') return { default: () => store }
if (name === '@/api/app') return {
getDoctorWebConsultationPolicy: () => {
requests++
return new Promise((resolve, reject) => { resolveRequest = resolve; rejectRequest = reject })
}
}
if (name === '@/utils/feedback') return { default: { msgWarning() {} } }
if (name === '@/utils/doctor-web-consultation') return policy
throw new Error(name)
}, {
Date: { now: () => clock }, window: surface, document: surface,
setInterval: fn => { ticks.add(fn); return fn }, clearInterval: fn => ticks.delete(fn)
})
const first = hook.useDoctorWebConsultation()
const second = hook.useDoctorWebConsultation()
assert.equal(first.webConsultationBlocked.value, true)
mounted.forEach(fn => fn())
assert.equal(requests, 1)
const initial = first.refreshConsultationPolicy()
assert.equal(second.refreshConsultationPolicy(), initial)
resolveRequest(shiftedSchedule('2027-06-15T14:35:12+08:00', clock, 10, 2))
await initial
assert.equal(first.webConsultationBlocked.value, false)
assert.equal(second.webConsultationBlocked.value, false)
assert.match(first.consultationReminder.value.description, /2027年06月15日14:35:12/)
clock += 59000
ticks.forEach(fn => fn())
assert.equal(requests, 1)
clock += 1000
ticks.forEach(fn => fn())
assert.equal(requests, 2)
const polled = first.refreshConsultationPolicy()
resolveRequest(shiftedSchedule('2027-06-15T13:59:00+08:00', clock))
await polled
assert.equal(second.webConsultationBlocked.value, true)
assert.match(second.webConsultationMessage.value, /2027年06月15日13:59/)
listeners.get('focus').forEach(fn => fn())
assert.equal(requests, 3)
const failed = first.refreshConsultationPolicy()
rejectRequest(new Error('offline'))
await failed
assert.equal(first.policyLoadFailed.value, true)
assert.equal(second.webConsultationBlocked.value, true)
listeners.get('visibilitychange').forEach(fn => fn())
assert.equal(requests, 4)
const retry = second.refreshConsultationPolicy()
resolveRequest(shiftedSchedule('2028-01-01T12:30:00+08:00', clock))
await retry
assert.equal(first.policyLoadFailed.value, false)
assert.equal(second.webConsultationBlocked.value, false)
assert.match(first.consultationReminder.value.description, /2028年01月01日12:30/)
const invalid = first.refreshConsultationPolicy()
resolveRequest({ cutoff_at: 'invalid' })
await invalid
assert.equal(first.policyLoadFailed.value, true)
assert.match(first.consultationReminder.value.description, /2028年01月01日12:30/)
unmounted.forEach(fn => fn())
assert.equal(ticks.size, 0)
assert.ok([...listeners.values()].every(callbacks => callbacks.size === 0))
})
test('the runtime policy endpoint is public and requests a fresh response', async () => {
let transport
const api = moduleFrom(read('api/app.ts'), name => {
if (name === '@/utils/request') return { default: { get: (...args) => { transport = args; return Promise.resolve(schedule) } } }
throw new Error(name)
})
assert.deepEqual(await api.getDoctorWebConsultationPolicy(), schedule)
assert.equal(transport[0].url, '/config/doctorWebConsultation')
assert.ok(transport[0].params._t > 0)
assert.equal(transport[1].withToken, false)
})
test('installer requests are public, deduplicated across notices, cached, and recover after failure', async () => {
let requests = 0, resolve
const mounted = [], unmounted = []
const api = { getDesktopWorkstationDownloads: () => { requests++; return new Promise(done => { resolve = done }) } }
const mod = moduleFrom(read('hooks/useDesktopWorkstationDownloads.ts'), name => {
if (name === 'vue') return { ...vue, onMounted: fn => mounted.push(fn), onUnmounted: fn => unmounted.push(fn) }
if (name === '@/api/setting/desktop_workstation') return api
throw new Error(name)
}, { window: { addEventListener() {}, removeEventListener() {} } })
const first = mod.useDesktopWorkstationDownloads()
const second = mod.useDesktopWorkstationDownloads()
const pending = first.loadDownloads()
assert.equal(second.loadDownloads(), pending)
assert.equal(requests, 1)
resolve({ version: '1.4.8', packages: [installer] })
await pending
await second.loadDownloads()
assert.equal(requests, 1)
assert.equal(second.downloads.value.packages.length, 1)
api.getDesktopWorkstationDownloads = async () => { throw new Error('offline') }
await first.loadDownloads(true)
assert.equal(first.downloadsFailed.value, true)
assert.equal(first.downloads.value.packages.length, 1)
api.getDesktopWorkstationDownloads = async () => ({ version: '1.4.9', packages: [installer] })
await second.loadDownloads(true)
assert.equal(first.downloadsFailed.value, false)
assert.equal(first.downloads.value.version, '1.4.9')
assert.deepEqual(mod.normalizeDesktopDownloads({ version: '1', packages: [null, { ...installer, url: 'javascript:alert(1)' }, { ...installer, url: 'https://user:pass@example.test/setup.exe' }, { ...installer, sha256: '' }] }).packages, [])
assert.equal(mod.normalizeDesktopDownloads({ version: '1', packages: [installer, installer] }).packages.length, 1)
unmounted.forEach(fn => fn())
let transport
const apiModule = moduleFrom(read('api/setting/desktop_workstation.ts'), name => {
if (name === '@/utils/request') return { default: { get: (...args) => { transport = args; return Promise.resolve({}) } } }
throw new Error(name)
})
await apiModule.getDesktopWorkstationDownloads()
assert.equal(transport[0].url, '/setting.desktop_workstation/downloads')
assert.equal(transport[1].withToken, false)
})
test('the actual reception, diagnosis and both appointment list entries compile with the shared notice', () => {
for (const name of ['views/patient/reception/index.vue', 'views/tcm/diagnosis/index.vue', 'views/tcm/appointment/list.vue', 'views/tcm/appointment/list_h5.vue']) {
const { descriptor, errors } = parse(read(name), { filename: name })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: name })
assert.equal(script.bindings.DoctorWebConsultationNotice, 'setup-const')
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: name, id: name, compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
}
})
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.4.5"
__version__ = "1.4.8"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
@@ -12,6 +12,7 @@ from urllib.parse import urljoin, urlsplit, urlunsplit
import httpx
from doctor_workstation import __version__
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
@@ -483,6 +484,7 @@ class ApiClient:
result = {
"Accept": "application/json",
"version": self.API_VERSION,
"User-Agent": f"ZytDoctorWorkstation/{__version__}",
}
if json_content_type:
result["Content-Type"] = "application/json;charset=UTF-8"
+376
View File
@@ -0,0 +1,376 @@
"""Shared, display-only normalization of archived chat messages and IM events."""
from __future__ import annotations
import json
import math
import re
from collections.abc import Mapping
from datetime import date, datetime
from typing import Any, NamedTuple
class ChatItem(NamedTuple):
variant: str
role: str
text: Any
title: str
detail: str
time_text: str
tone: str
marker: str
def _first_value(source: Any, *keys: str, default: Any = None) -> Any:
for key in keys:
if isinstance(source, Mapping):
value = source.get(key)
else:
value = getattr(source, key, None)
raw = getattr(source, "raw", None)
if value is None and isinstance(raw, Mapping):
value = raw.get(key)
if value is not None and value != "":
return value
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return default
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "是", "有"}
def format_chat_time(value: Any, *, include_date: bool = False) -> str:
"""Show actual archive times; accept Unix seconds/milliseconds and ISO dates."""
if value is None or value == "" or isinstance(value, bool):
return ""
pattern = "%Y-%m-%d %H:%M" if include_date else "%H:%M"
if isinstance(value, datetime):
return value.strftime(pattern)
if isinstance(value, date):
return value.isoformat()
text = str(value).strip()
if not text:
return ""
if isinstance(value, (int, float)) or re.fullmatch(r"[+-]?\d+(?:\.\d+)?", text):
numeric = float(value)
if not math.isfinite(numeric) or numeric <= 0:
return ""
if numeric >= 10_000_000_000:
numeric /= 1000
try:
return datetime.fromtimestamp(numeric).strftime(pattern)
except (OverflowError, OSError, ValueError):
return ""
try:
moment = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
# Legacy archive rows can already contain a display-only time.
return text if include_date else text.rsplit(" ", 1)[-1][:16]
return moment.strftime(pattern)
def _decode_json_value(value: Any) -> Any:
if isinstance(value, Mapping):
return dict(value)
if isinstance(value, (list, tuple)):
return list(value)
if not isinstance(value, str):
return None
text = value.strip()
if not text or text[0] not in "[{":
return None
try:
return json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return None
def _normalized_token(value: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").strip().lower())
def _format_call_duration(value: Any) -> str:
seconds = _as_int(value)
if seconds <= 0:
return ""
if seconds < 60:
return f"通话 {seconds} 秒"
minutes, remaining = divmod(seconds, 60)
return f"通话 {minutes} 分 {remaining} 秒" if remaining else f"通话 {minutes} 分钟"
def _event_time(
row: Mapping[str, Any], *payloads: Mapping[str, Any], include_date: bool = False
) -> str:
value = _first_value(
row, "time", "create_time", "created_at", "msg_time", "timestamp", default=None
)
if value in (None, ""):
for payload in payloads:
value = _first_value(payload, "time", "timestamp", "create_time", default=None)
if value not in (None, ""):
break
return format_chat_time(value, include_date=include_date)
def _event_item(
*,
title: str,
detail: str = "",
time_text: str = "",
tone: str = "neutral",
marker: str = "系",
) -> ChatItem:
return ChatItem("event", "system", "", title, detail, time_text, tone, marker)
def _system_event_from_payload(
payload: Mapping[str, Any],
row: Mapping[str, Any],
*,
include_date: bool = False,
) -> ChatItem | None:
business = _normalized_token(_first_value(payload, "businessID", "businessId", "business_id"))
time_text = _event_time(row, payload, include_date=include_date)
patient_name = str(
_first_value(payload, "patientName", "patient_name", default="患者") or "患者"
)
if business in {"patientopenedchat", "patientopenchat", "openchat"}:
return _event_item(
title="患者进入在线问诊",
detail=f"{patient_name}已进入当前会话",
time_text=time_text,
tone="success",
marker="入",
)
if business in {"patientclosedchat", "patientclosechat", "patientleftchat", "closechat"}:
return _event_item(
title="患者离开在线问诊",
detail=f"{patient_name}已离开当前会话",
time_text=time_text,
marker="离",
)
if business == "doctorenteredconsultroom":
return _event_item(
title="医生进入在线诊室",
detail="本次在线问诊已准备就绪",
time_text=time_text,
tone="success",
marker="医",
)
if business == "consultationcomplete":
return _event_item(
title="本次问诊已完成",
detail="会话内容已归档",
time_text=time_text,
tone="success",
marker="完",
)
nested = _decode_json_value(payload.get("data"))
rtc_payload: Mapping[str, Any] | None = None
if business in {"rtccall", "rtcvideo", "rtcaudio"}:
rtc_payload = payload
elif business in {"1", "avcall"}:
rtc_payload = {**payload, **nested} if isinstance(nested, Mapping) else payload
elif _first_value(payload, "cmd", "command", default="") not in (None, ""):
rtc_payload = payload
elif isinstance(nested, Mapping):
nested_business = _normalized_token(
_first_value(nested, "businessID", "businessId", "business_id")
)
if nested_business in {"rtccall", "rtcvideo", "rtcaudio"}:
rtc_payload = nested
else:
nested_event = _system_event_from_payload(nested, row, include_date=include_date)
if nested_event is not None:
return nested_event
if rtc_payload is None:
return None
call_data = _decode_json_value(rtc_payload.get("data"))
if not isinstance(call_data, Mapping):
call_data = rtc_payload
command = _normalized_token(_first_value(call_data, "cmd", "command", "action"))
action_type = _as_int(_first_value(rtc_payload, "actionType", "action_type"))
if not command:
command = {
1: "invite",
2: "cancel",
3: "accept",
4: "reject",
5: "timeout",
6: "hangup",
7: "linebusy",
}.get(action_type, "")
call_type = _as_int(_first_value(rtc_payload, "call_type", default=2), 2)
medium = (
"语音" if call_type == 1 or business == "rtcaudio" or command == "audiocall" else "视频"
)
marker = "音" if medium == "语音" else "视"
time_text = _event_time(row, payload, rtc_payload, call_data, include_date=include_date)
duration = _format_call_duration(
_first_value(
rtc_payload,
"call_end",
"duration",
"callDuration",
default=_first_value(call_data, "call_end", "duration", "callDuration", default=0),
)
)
if command in {"videocall", "audiocall", "call", "invite", "startcall"}:
return _event_item(
title=f"发起{medium}问诊",
detail=f"医生已向患者发起{medium}通话",
time_text=time_text,
tone="info",
marker=marker,
)
if command in {"accept", "answer", "answered", "connected", "join"}:
return _event_item(
title=f"{medium}问诊已接通",
detail="医生与患者已进入通话",
time_text=time_text,
tone="success",
marker=marker,
)
if command in {"hangup", "end", "endcall", "finish", "finished"}:
return _event_item(
title=f"{medium}问诊已结束",
detail=duration,
time_text=time_text,
marker=marker,
)
if command in {"reject", "refuse", "decline"}:
return _event_item(
title=f"{medium}问诊未接通",
detail="患者已拒绝本次通话邀请",
time_text=time_text,
tone="warning",
marker=marker,
)
if command in {"timeout", "busy", "linebusy", "cancel", "cancelled", "canceled"}:
reasons = {
"timeout": "呼叫超时,患者未接听",
"busy": "患者当前忙线",
"linebusy": "患者当前忙线",
"cancel": "通话邀请已取消",
"cancelled": "通话邀请已取消",
"canceled": "通话邀请已取消",
}
return _event_item(
title=f"{medium}问诊未接通",
detail=reasons[command],
time_text=time_text,
tone="warning",
marker=marker,
)
if command in {"switchtoaudio", "switchaudio"}:
return _event_item(
title="通话已切换为语音",
detail="视频画面已关闭",
time_text=time_text,
tone="info",
marker="音",
)
if command in {"switchtovideo", "switchvideo"}:
return _event_item(
title="通话已切换为视频",
time_text=time_text,
tone="info",
marker="视",
)
return _event_item(
title=f"{medium}问诊状态已更新",
detail="通话信令已归档",
time_text=time_text,
tone="info",
marker=marker,
)
def parse_chat_item(row: Mapping[str, Any], *, include_date: bool = False) -> ChatItem | None:
raw_text = _first_value(row, "text", "content", "message", default="")
payload = _decode_json_value(raw_text)
if isinstance(payload, Mapping):
business = _normalized_token(
_first_value(payload, "businessID", "businessId", "business_id")
)
if business == "usertypingstatus":
return None
event = _system_event_from_payload(payload, row, include_date=include_date)
if event is not None:
return event
kind = str(
_first_value(row, "msg_type", "message_type", "type", default="text") or "text"
).lower()
technical_keys = {
"businessID",
"businessId",
"business_id",
"actionType",
"inviteID",
"cmd",
"command",
}
if isinstance(payload, Mapping) and (
kind in {"custom", "other", "system"} or technical_keys.intersection(payload)
):
return _event_item(
title="系统状态已同步",
detail="技术消息已归档,不影响问诊内容",
time_text=_event_time(row, payload, include_date=include_date),
)
doctor_flag = _first_value(row, "is_from_doctor", "from_doctor", default=False)
sender_role = _normalized_token(_first_value(row, "role", "sender_type", default=""))
from_account = str(_first_value(row, "from_account", "sender", default="") or "").lower()
from_doctor = (
_truthy(doctor_flag)
or sender_role in {"doctor", "staff", "2"}
or from_account.startswith("doctor_")
)
from_ai = sender_role in {"ai", "assistant", "model"} or from_account.startswith(
("ai_", "assistant_")
)
role = "ai" if from_ai else "doctor" if from_doctor else "patient"
time_text = _event_time(row, include_date=include_date)
attachment_labels = {
"image": "图片消息",
"2": "图片消息",
"file": "附件",
"3": "附件",
"sound": "语音消息",
"audio": "语音消息",
"video": "视频消息",
"location": "位置消息",
"face": "表情消息",
}
if kind in attachment_labels:
file_name = str(_first_value(row, "file_name", default="") or "").strip()
detail = str(raw_text or "").strip()
if kind in {"file", "3"} and file_name:
text: Any = f"附件:{file_name}"
elif kind == "location" and detail:
text = f"位置:{detail}"
else:
text = attachment_labels[kind]
elif isinstance(payload, (Mapping, list)):
text = payload
else:
text = str(raw_text or _first_value(row, "file_name", default="") or "").strip()
if text in (None, "", {}, []):
return None
return ChatItem("message", role, text, "", "", time_text, "", "")
@@ -63,7 +63,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from .diagnosis_editors import FlowLayout
from .chat_items import ChatItem, parse_chat_item
from .diagnosis_editors import FlowLayout
from .diagnosis_media import shared_image_manager
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
@@ -720,6 +721,25 @@ QFrame#DiagnosisChatBubble[side="doctor"] {
border: 1px solid #F0F0F0;
border-radius: 8px;
}
QFrame#DiagnosisChatEvent {
background-color: #F7F8FA;
border: 1px solid #EDEDEE;
border-radius: 8px;
}
QLabel#DiagnosisChatEventMarker {
color: #697386;
font-size: 12px;
font-weight: 600;
}
QLabel#DiagnosisChatEventTitle {
color: #374151;
font-size: 13px;
font-weight: 600;
}
QLabel#DiagnosisChatEventDetail {
color: #697386;
font-size: 12px;
}
QLabel#DiagnosisChatMeta {
color: #606163;
font-size: 12px;
@@ -3034,25 +3054,16 @@ class ChatPanel(QWidget):
widget = item.widget()
if widget is not None and widget not in {self.empty, self.toolbar, self.info}:
widget.deleteLater()
self.empty.setVisible(not messages)
visible_count = 0
for message in messages:
doctor_flag = _pick(message, "is_from_doctor", default=False)
is_doctor = (
doctor_flag.strip().lower() not in {"", "0", "false", "no"}
if isinstance(doctor_flag, str)
else bool(doctor_flag)
)
side = (
"doctor"
if is_doctor
or str(_pick(message, "role", "sender_type", default="")).lower()
in {
"doctor",
"staff",
"2",
}
else "patient"
)
item = parse_chat_item(message, include_date=True)
if item is None:
continue
visible_count += 1
if item.variant == "event":
self.root.insertWidget(max(2, self.root.count() - 1), self._event_row(item))
continue
side = "doctor" if item.role == "doctor" else "patient"
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
@@ -3065,13 +3076,14 @@ class ChatPanel(QWidget):
bubble_layout.setSpacing(5)
meta = QLabel(
f"{_text(_pick(message, 'sender_name', 'from_staff_name', 'name'), '患者' if side == 'patient' else '医生')} "
f"{_text(_pick(message, 'create_time', 'time'), '')}"
f"{item.time_text}"
)
meta.setObjectName("DiagnosisChatMeta")
meta.setTextFormat(Qt.TextFormat.PlainText)
message_type = str(
_pick(message, "message_type", "msg_type", "type", default="text") or "text"
).lower()
body_text = _text(_pick(message, "content", "text", "message"), "不支持的消息")
body_text = _text(_pick(message, "text", "content", "message"), "不支持的消息")
body: QWidget
if message_type in {"image", "2"}:
target = str(
@@ -3111,9 +3123,11 @@ class ChatPanel(QWidget):
)
body = action
else:
text = QLabel(body_text)
text = QLabel(body_text)
text.setObjectName("DiagnosisChatText")
text.setTextFormat(Qt.TextFormat.PlainText)
text.setWordWrap(True)
text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
body = text
bubble_layout.addWidget(meta)
bubble_layout.addWidget(body)
@@ -3124,6 +3138,50 @@ class ChatPanel(QWidget):
row_layout.addWidget(bubble)
row_layout.addStretch(1)
self.root.insertWidget(max(2, self.root.count() - 1), row)
self.empty.setVisible(visible_count == 0)
@staticmethod
def _event_row(item: ChatItem) -> QWidget:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.addStretch(1)
card = QFrame()
card.setObjectName("DiagnosisChatEvent")
card.setProperty("tone", item.tone)
card.setMaximumWidth(620)
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
content = QGridLayout(card)
content.setContentsMargins(12, 10, 12, 10)
content.setHorizontalSpacing(9)
content.setVerticalSpacing(4)
marker = QLabel(item.marker)
marker.setObjectName("DiagnosisChatEventMarker")
marker.setTextFormat(Qt.TextFormat.PlainText)
marker.setFixedWidth(22)
marker.setAlignment(Qt.AlignmentFlag.AlignCenter)
content.addWidget(marker, 0, 0)
title = QLabel(item.title)
title.setObjectName("DiagnosisChatEventTitle")
title.setTextFormat(Qt.TextFormat.PlainText)
title.setWordWrap(True)
content.addWidget(title, 0, 1)
content.setColumnStretch(1, 1)
if item.time_text:
time = QLabel(item.time_text)
time.setObjectName("DiagnosisChatMeta")
time.setTextFormat(Qt.TextFormat.PlainText)
content.addWidget(time, 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
if item.detail:
detail = QLabel(item.detail)
detail.setObjectName("DiagnosisChatEventDetail")
detail.setTextFormat(Qt.TextFormat.PlainText)
detail.setWordWrap(True)
detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
content.addWidget(detail, 1, 1, 1, 2)
layout.addWidget(card, 4)
layout.addStretch(1)
return row
@dataclass(frozen=True)
@@ -9,7 +9,6 @@ from collections.abc import Container, Mapping, Sequence
from contextlib import suppress
from copy import deepcopy
from datetime import datetime
from html import escape
from threading import Event
from time import monotonic
from typing import Any
@@ -18,9 +17,12 @@ from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, S
from PySide6.QtGui import (
QIcon,
QKeyEvent,
QTextCharFormat,
QTextCursor,
QTextDocument,
)
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFrame,
QGridLayout,
@@ -40,6 +42,7 @@ from PySide6.QtWidgets import (
)
from .. import icons
from ..chat_items import parse_chat_item as _parse_chat_item
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
from ..diagnosis_media import (
ImagePreviewDialog,
@@ -69,6 +72,20 @@ from ..widgets import (
run_async,
show_toast,
)
from .ai_consult_charts import GlucoseChartData
from .ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
UNKNOWN_COLOR,
combined_metric_state,
risk_color,
)
from .ai_consult_metric_text import MetricTextState, metric_text_context, metric_text_html
from .ai_consult_report_visuals import (
ReportVisual,
build_report_visuals,
create_report_visual,
)
from .prescription_ai import can_use_diagnosis_ai_assistant, diagnosis_ai_task
AI_CONSULT_QSS = """
@@ -949,10 +966,6 @@ _STRUCTURED_FIELD_LABELS = {
"message": "消息",
"status": "状态",
}
_ChatItem = namedtuple(
"_ChatItem",
"variant role text title detail time_text tone marker",
)
def can_open_ai_consult(permissions: Any) -> bool:
@@ -1014,27 +1027,6 @@ def _paint_icon(kind: str, color: str = "accent", size: int = 18) -> QIcon:
return icons.icon(kind, color, size)
def _format_time(value: Any) -> str:
if value in (None, ""):
return ""
numeric: float | None = None
if isinstance(value, (int, float)):
numeric = float(value)
elif isinstance(value, str) and value.strip().isdigit():
numeric = float(value.strip())
if numeric is not None and numeric > 1_000_000_000:
if numeric > 100_000_000_000:
numeric /= 1000
try:
return datetime.fromtimestamp(numeric).strftime("%H:%M")
except (OverflowError, OSError, ValueError):
return ""
text = str(value)
if " " in text:
return text.split(" ")[-1][:5]
return text[:16]
def _join_meta(*parts: Any) -> str:
return " · ".join(
str(part).strip()
@@ -1043,22 +1035,6 @@ def _join_meta(*parts: Any) -> str:
)
def _decode_json_value(value: Any) -> Any:
if isinstance(value, Mapping):
return dict(value)
if isinstance(value, (list, tuple)):
return list(value)
if not isinstance(value, str):
return None
text = value.strip()
if not text or text[0] not in "[{":
return None
try:
return json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return None
def _structured_markdown(value: Any, *, depth: int = 0) -> str:
"""Turn small structured replies into readable clinical copy, not code dumps."""
@@ -1087,258 +1063,6 @@ def _structured_markdown(value: Any, *, depth: int = 0) -> str:
return str(value)
def _normalized_token(value: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").strip().lower())
def _format_call_duration(value: Any) -> str:
seconds = _as_int(value)
if seconds <= 0:
return ""
if seconds < 60:
return f"通话 {seconds} 秒"
minutes, remaining = divmod(seconds, 60)
return f"通话 {minutes} 分 {remaining} 秒" if remaining else f"通话 {minutes} 分钟"
def _event_time(row: Mapping[str, Any], *payloads: Mapping[str, Any]) -> str:
value = first_value(row, "time", "create_time", "created_at", "msg_time", default=None)
if value in (None, ""):
for payload in payloads:
value = first_value(payload, "time", "timestamp", "create_time", default=None)
if value not in (None, ""):
break
return _format_time(value)
def _event_item(
*,
title: str,
detail: str = "",
time_text: str = "",
tone: str = "neutral",
marker: str = "系",
) -> _ChatItem:
return _ChatItem("event", "system", "", title, detail, time_text, tone, marker)
def _system_event_from_payload(
payload: Mapping[str, Any],
row: Mapping[str, Any],
) -> _ChatItem | None:
business = _normalized_token(first_value(payload, "businessID", "businessId", "business_id"))
time_text = _event_time(row, payload)
patient_name = str(first_value(payload, "patientName", "patient_name", default="患者") or "患者")
if business in {"patientopenedchat", "patientopenchat", "openchat"}:
return _event_item(
title="患者进入在线问诊",
detail=f"{patient_name}已进入当前会话",
time_text=time_text,
tone="success",
marker="入",
)
if business in {"patientclosedchat", "patientclosechat", "patientleftchat", "closechat"}:
return _event_item(
title="患者离开在线问诊",
detail=f"{patient_name}已离开当前会话",
time_text=time_text,
marker="离",
)
if business == "doctorenteredconsultroom":
return _event_item(
title="医生进入在线诊室",
detail="本次在线问诊已准备就绪",
time_text=time_text,
tone="success",
marker="医",
)
if business == "consultationcomplete":
return _event_item(
title="本次问诊已完成",
detail="会话内容已归档",
time_text=time_text,
tone="success",
marker="完",
)
nested = _decode_json_value(payload.get("data"))
rtc_payload: Mapping[str, Any] | None = None
if business in {"rtccall", "rtcvideo", "rtcaudio"}:
rtc_payload = payload
elif business in {"1", "avcall"}:
rtc_payload = {**payload, **nested} if isinstance(nested, Mapping) else payload
elif first_value(payload, "cmd", "command", default="") not in (None, ""):
rtc_payload = payload
elif isinstance(nested, Mapping):
nested_business = _normalized_token(
first_value(nested, "businessID", "businessId", "business_id")
)
if nested_business in {"rtccall", "rtcvideo", "rtcaudio"}:
rtc_payload = nested
else:
nested_event = _system_event_from_payload(nested, row)
if nested_event is not None:
return nested_event
if rtc_payload is None:
return None
call_data = _decode_json_value(rtc_payload.get("data"))
if not isinstance(call_data, Mapping):
call_data = rtc_payload
command = _normalized_token(first_value(call_data, "cmd", "command", "action"))
action_type = _as_int(first_value(rtc_payload, "actionType", "action_type"))
if not command:
command = {
1: "invite",
2: "cancel",
3: "accept",
4: "reject",
5: "timeout",
6: "hangup",
7: "linebusy",
}.get(action_type, "")
call_type = _as_int(first_value(rtc_payload, "call_type", default=2), 2)
medium = "语音" if call_type == 1 or business == "rtcaudio" else "视频"
marker = "音" if medium == "语音" else "视"
time_text = _event_time(row, payload, rtc_payload, call_data)
duration = _format_call_duration(
first_value(rtc_payload, "call_end", "duration", "callDuration", default=0)
)
if command in {"videocall", "audiocall", "call", "invite", "startcall"}:
return _event_item(
title=f"发起{medium}问诊",
detail=f"医生已向患者发起{medium}通话",
time_text=time_text,
tone="info",
marker=marker,
)
if command in {"accept", "answer", "answered", "connected", "join"}:
return _event_item(
title=f"{medium}问诊已接通",
detail="医生与患者已进入通话",
time_text=time_text,
tone="success",
marker=marker,
)
if command in {"hangup", "end", "endcall", "finish", "finished"}:
return _event_item(
title=f"{medium}问诊已结束",
detail=duration,
time_text=time_text,
marker=marker,
)
if command in {"reject", "refuse", "decline"}:
return _event_item(
title=f"{medium}问诊未接通",
detail="患者已拒绝本次通话邀请",
time_text=time_text,
tone="warning",
marker=marker,
)
if command in {"timeout", "busy", "linebusy", "cancel", "cancelled", "canceled"}:
reasons = {
"timeout": "呼叫超时,患者未接听",
"busy": "患者当前忙线",
"linebusy": "患者当前忙线",
"cancel": "通话邀请已取消",
"cancelled": "通话邀请已取消",
"canceled": "通话邀请已取消",
}
return _event_item(
title=f"{medium}问诊未接通",
detail=reasons[command],
time_text=time_text,
tone="warning",
marker=marker,
)
if command in {"switchtoaudio", "switchaudio"}:
return _event_item(
title="通话已切换为语音",
detail="视频画面已关闭",
time_text=time_text,
tone="info",
marker="音",
)
if command in {"switchtovideo", "switchvideo"}:
return _event_item(
title="通话已切换为视频",
time_text=time_text,
tone="info",
marker="视",
)
return _event_item(
title=f"{medium}问诊状态已更新",
detail="通话信令已归档",
time_text=time_text,
tone="info",
marker=marker,
)
def _parse_chat_item(row: Mapping[str, Any]) -> _ChatItem | None:
raw_text = first_value(row, "text", "content", "message", default="")
payload = _decode_json_value(raw_text)
if isinstance(payload, Mapping):
business = _normalized_token(
first_value(payload, "businessID", "businessId", "business_id")
)
if business == "usertypingstatus":
return None
event = _system_event_from_payload(payload, row)
if event is not None:
return event
kind = str(first_value(row, "msg_type", "message_type", "type", default="text") or "text").lower()
technical_keys = {"businessID", "businessId", "actionType", "inviteID", "cmd", "command"}
if isinstance(payload, Mapping) and (
kind in {"custom", "other", "system"} or technical_keys.intersection(payload)
):
return _event_item(
title="系统状态已同步",
detail="技术消息已归档,不影响问诊内容",
time_text=_event_time(row, payload),
)
doctor_flag = first_value(row, "is_from_doctor", "from_doctor", default=False)
sender_role = _normalized_token(first_value(row, "role", "sender_type", default=""))
from_account = str(first_value(row, "from_account", "sender", default="") or "").lower()
from_doctor = _truthy(doctor_flag) or sender_role in {"doctor", "staff", "2"} or from_account.startswith("doctor_")
from_ai = sender_role in {"ai", "assistant", "model"} or from_account.startswith(
("ai_", "assistant_")
)
role = "ai" if from_ai else "doctor" if from_doctor else "patient"
time_text = _event_time(row)
attachment_labels = {
"image": "图片消息",
"2": "图片消息",
"file": "附件",
"3": "附件",
"sound": "语音消息",
"audio": "语音消息",
"video": "视频消息",
"location": "位置消息",
"face": "表情消息",
}
if kind in attachment_labels:
file_name = str(first_value(row, "file_name", default="") or "").strip()
detail = str(raw_text or "").strip()
if kind in {"file", "3"} and file_name:
text: Any = f"附件:{file_name}"
elif kind == "location" and detail:
text = f"位置:{detail}"
else:
text = attachment_labels[kind]
elif isinstance(payload, (Mapping, list)):
text = payload
else:
text = str(raw_text or first_value(row, "file_name", default="") or "").strip()
if text in (None, "", {}, []):
return None
return _ChatItem("message", role, text, "", "", time_text, "", "")
def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") -> None:
"""Render markdown, HTML, JSON or plain text inside a chat bubble."""
@@ -2006,7 +1730,10 @@ def _clinical_entries(text: str, *, limit: int) -> list[_ClinicalEntry]:
def _clinical_evidence(text: str) -> list[_ClinicalEvidence]:
evidence: list[_ClinicalEvidence] = []
glucose = re.search(
r"空腹血糖(?:偏高)?[^0-9]{0,10}([0-9]+(?:\.[0-9]+)?)\s*(mmol/L)?",
r"空腹血糖(?:偏高)?[^0-9<>≤≥]{0,10}"
r"([<>≤≥]?\s*[0-9]+(?:\.[0-9]+)?"
r"(?:\s*(?:[-~~–—]|至|到)\s*[0-9]+(?:\.[0-9]+)?)?)"
r"(?![0-9.xX点~~–—-]|至|到|左右|多|余)\s*(mmol/L)?",
text,
re.IGNORECASE,
)
@@ -2168,8 +1895,9 @@ def _report_provenance_widget(raw: Any, parent: QWidget) -> QFrame:
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
_INSIGHT_LIST_LOOKAHEAD_RE = re.compile(r"(?:^|(?<=[::;;。\s]))(\d{1,2})\s*([\.、))])\s*")
_INSIGHT_MARKER = r"(\d{1,2})\s*(\.(?!\d)|[、))])\s*"
_INSIGHT_LIST_RE = re.compile(r"^\s*" + _INSIGHT_MARKER)
_INSIGHT_LIST_LOOKAHEAD_RE = re.compile(r"(?:^|(?<=[::;;。\s]))" + _INSIGHT_MARKER)
_INSIGHT_LABEL_RE = re.compile(r"^([\u4e00-\u9fa5A-Za-z()()·\s]{1,12})[::]\s*(.*)$")
@@ -2243,10 +1971,7 @@ def _split_numbered_list(text: str) -> list[_InsightItem]:
candidate_ends: list[int] = []
if sentence_match is not None:
candidate_ends.append(sentence_match.end())
next_marker_match = re.search(
r"\s\d{1,2}\s*[\.、))]",
after_marker,
)
next_marker_match = re.search(r"\s" + _INSIGHT_MARKER, after_marker)
if next_marker_match is not None:
candidate_ends.append(next_marker_match.start() + 1)
inner_end = min(candidate_ends) if candidate_ends else len(after_marker)
@@ -2372,10 +2097,10 @@ _REPORT_ORDINAL_RE = re.compile(r"^\s*(?:\d{1,2}|[一二三四五六七八九十
_READING_LINE_HEIGHT = 172
def _reading_html(text: str) -> str:
def _reading_html(text: str, *, metric_label: str = "") -> str:
"""Wrap plain report text in the reading rhythm used across the workspace."""
escaped = escape(str(text or "").strip()).replace("\n", "<br>")
escaped = metric_text_html(str(text or "").strip(), metric_label=metric_label)
return f'<div style="line-height:{_READING_LINE_HEIGHT}%">{escaped}</div>'
@@ -2438,6 +2163,47 @@ class _FittedText(QLabel):
self._fitting = False
def _fitted_metric_label(text: str, parent: QWidget) -> _FittedText:
html = metric_text_html(text)
highlighted = "color:" in html
return _FittedText(html if highlighted else text, rich=highlighted, parent=parent)
def _set_metric_label(label: QLabel, text: str, *, metric_label: str = "") -> None:
html = metric_text_html(text, metric_label=metric_label)
highlighted = "color:" in html
label.setTextFormat(Qt.TextFormat.RichText if highlighted else Qt.TextFormat.PlainText)
label.setText(html if highlighted else text)
def _highlight_document_metrics(document: QTextDocument) -> None:
"""Keep Markdown structure while emphasizing metrics in a short completed reply."""
text = document.toPlainText()
colored = QTextDocument()
colored.setHtml(metric_text_html(text))
if colored.toPlainText() != text:
return
cursor = QTextCursor(document)
cursor.beginEditBlock()
block = colored.begin()
while block.isValid():
fragments = block.begin()
while not fragments.atEnd():
fragment = fragments.fragment()
foreground = fragment.charFormat().foreground()
if foreground.style() != Qt.BrushStyle.NoBrush:
style = QTextCharFormat()
style.setForeground(foreground)
style.setFontWeight(fragment.charFormat().fontWeight())
cursor.setPosition(fragment.position())
cursor.setPosition(fragment.position() + fragment.length(), QTextCursor.MoveMode.KeepAnchor)
cursor.mergeCharFormat(style)
fragments += 1
block = block.next()
cursor.endEditBlock()
_REPORT_LEAD_RE = re.compile(r"^\s*(?P<label>[^::。;;!!??]{2,20})\s*[::]\s*(?P<body>.+)$")
@@ -2528,6 +2294,9 @@ class _StructuredReportPanel(QWidget):
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
root.addStretch(1)
def set_time_text(self, text: str) -> None:
self.meta_label.setText(text)
def _section_card(self, index: int, section: _ReportSection) -> QFrame:
card = QFrame(self)
card.setObjectName("AiConsultReportSection")
@@ -2545,9 +2314,7 @@ class _StructuredReportPanel(QWidget):
chip.setAlignment(Qt.AlignmentFlag.AlignCenter)
head.addWidget(chip, 0, Qt.AlignmentFlag.AlignTop)
# 序号已经在圆点里,标题再带一次 "1." 只是噪音。
title = _FittedText(
_strip_leading_ordinal(section.title), rich=False, parent=card
)
title = _fitted_metric_label(_strip_leading_ordinal(section.title), card)
title.setObjectName("AiConsultReportSectionTitle")
head.addWidget(title, 1)
layout.addLayout(head)
@@ -2579,10 +2346,10 @@ class _StructuredReportPanel(QWidget):
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(3)
if label:
lead = _FittedText(label, rich=False, parent=host)
lead = _fitted_metric_label(label, host)
lead.setObjectName("AiConsultReportLead")
column.addWidget(lead)
body = _FittedText(_reading_html(body_text), parent=host)
body = _FittedText(_reading_html(body_text, metric_label=label), parent=host)
body.setObjectName("AiConsultReportBody")
body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
column.addWidget(body)
@@ -2693,7 +2460,7 @@ class _ClinicalAnalysisPanel(QWidget):
_style_surface(disclaimer)
root.addWidget(disclaimer)
root.addWidget(_report_provenance_widget(provenance, self))
QTimer.singleShot(0, self._apply_responsive_layout)
QTimer.singleShot(0, self, self._apply_responsive_layout)
def set_time_text(self, time_text: str) -> None:
self.meta_label.setText(
@@ -2725,6 +2492,7 @@ class _ClinicalAnalysisPanel(QWidget):
label = QLabel(item.label)
label.setObjectName("AiConsultEvidenceLabel")
value = QLabel(item.value)
_set_metric_label(value, item.value, metric_label=item.label)
value.setObjectName("AiConsultEvidenceValue")
value.setWordWrap(True)
copy.addWidget(label)
@@ -2749,6 +2517,7 @@ class _ClinicalAnalysisPanel(QWidget):
card_layout.setSpacing(6)
heading = QHBoxLayout()
title = QLabel(entry.title)
_set_metric_label(title, entry.title)
title.setObjectName("AiConsultHypothesisTitle")
title.setWordWrap(True)
heading.addWidget(title, 1)
@@ -2792,6 +2561,7 @@ class _ClinicalAnalysisPanel(QWidget):
if entry.details:
copy += ":" + ";".join(entry.details)
text = QLabel(_compact_copy(copy, max_chars=82))
_set_metric_label(text, _compact_copy(copy, max_chars=82))
text.setObjectName("AiConsultGapText")
text.setWordWrap(True)
text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
@@ -2814,6 +2584,7 @@ class _ClinicalAnalysisPanel(QWidget):
_style_surface(number)
layout.addWidget(number, 0, Qt.AlignmentFlag.AlignHCenter)
title = QLabel(entry.title)
_set_metric_label(title, entry.title)
title.setObjectName("AiConsultWorkflowTitle")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setWordWrap(True)
@@ -2852,6 +2623,7 @@ class _ClinicalAnalysisPanel(QWidget):
item_layout.setContentsMargins(0, 0, 0, 0)
item_layout.setSpacing(3)
title = QLabel(entry.title)
_set_metric_label(title, entry.title)
title.setObjectName("AiConsultRiskTitle")
title.setWordWrap(True)
item_layout.addWidget(title)
@@ -2903,6 +2675,83 @@ class _ClinicalAnalysisPanel(QWidget):
)
def _metric_text_states(
visuals: Sequence[GlucoseChartData | ReportVisual],
) -> tuple[MetricTextState, ...]:
"""Bind prose to the same immutable, owned measurements used by the charts."""
states = []
glucose_fields = (
("fasting", "空腹血糖"), ("postprandial", "餐后血糖"), ("other", "其他血糖"),
)
for visual in visuals:
if isinstance(visual, GlucoseChartData):
for reading in visual.readings:
for field, label in glucose_fields:
value = getattr(reading, field)
if value is not None:
states.append(MetricTextState(
label, f"{value:g} mmol/L", getattr(reading, f"{field}_status", ""),
))
continue
if visual.kind not in {"metrics", "blood_pressure"}:
continue
systolic = None
for item in visual.items:
if visual.kind == "blood_pressure" and item.series == "systolic_pressure":
systolic = item if item.value is not None else None
if item.value is None and item.interval is None:
if visual.kind == "blood_pressure" and item.series == "diastolic_pressure":
systolic = None
continue
states.append(MetricTextState(item.label, item.value_text, item.status))
if item.label == "BMI" and item.value is not None:
# BMI is also displayed without its conventional unit in prose and key facts.
states.append(MetricTextState(item.label, item.raw_value, item.status))
if visual.kind != "blood_pressure":
continue
if (item.series == "diastolic_pressure" and systolic is not None
and systolic.recorded_at == item.recorded_at):
states.append(MetricTextState(
"血压", f"{systolic.value:g}/{item.value:g} mmHg",
combined_metric_state([systolic.status, item.status]),
))
systolic = None
return tuple(states)
def _report_visual_widget(
visuals: Sequence[GlucoseChartData | ReportVisual], parent: QWidget
) -> QWidget:
if len(visuals) == 1:
return create_report_visual(visuals[0], parent)
host = QWidget(parent)
host.setObjectName("AiConsultReportVisualGroup")
host.setMinimumWidth(0)
host.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
layout = QVBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
selector = QComboBox(host)
selector.setObjectName("AiConsultReportVisualSelector")
selector.setAccessibleName("切换报告相关数据图表")
selector.setToolTip("根据本次分析内容与已有记录选择图表")
for data in visuals:
selector.addItem("血糖变化趋势" if isinstance(data, GlucoseChartData) else data.title)
layout.addWidget(selector)
cards = [create_report_visual(data, host) for data in visuals]
for index, card in enumerate(cards):
layout.addWidget(card)
card.setVisible(index == 0)
def select(index: int) -> None:
for position, card in enumerate(cards):
card.setVisible(position == index)
host.updateGeometry()
selector.currentIndexChanged.connect(select)
return host
class _ChatBubble(QWidget):
def __init__(
self,
@@ -2917,7 +2766,8 @@ class _ChatBubble(QWidget):
self._role = role
self._raw_payload = text
self._provenance: Mapping[str, Any] = {}
self._clinical_panel: _ClinicalAnalysisPanel | None = None
self._clinical_panel: _ClinicalAnalysisPanel | _StructuredReportPanel | None = None
self._standalone_visuals: list[QWidget] = []
row = QHBoxLayout(self)
row.setContentsMargins(12, 7, 12, 7)
row.setSpacing(10)
@@ -2999,11 +2849,23 @@ class _ChatBubble(QWidget):
else:
self.stamp.show()
def finalize_clinical_analysis(self) -> bool:
def finalize_clinical_analysis(
self,
*,
chart_data: GlucoseChartData | None = None,
visuals: Sequence[GlucoseChartData | ReportVisual] = (),
) -> bool:
"""Replace a completed long clinical reply with its scan-first panel."""
if self._role != "ai" or self.body is None:
return False
report_visuals = tuple(visuals)
if chart_data is not None and chart_data.available:
report_visuals = (chart_data, *report_visuals)
with metric_text_context(_metric_text_states(report_visuals)):
return self._finalize_report_panel(report_visuals)
def _finalize_report_panel(self, report_visuals: Sequence[GlucoseChartData | ReportVisual]) -> bool:
model = _parse_clinical_analysis(self._raw_payload)
panel: QWidget | None = None
if model is not None:
@@ -3024,9 +2886,16 @@ class _ChatBubble(QWidget):
provenance=self._provenance,
parent=self._bubble_frame,
)
if panel is None:
return False
self._reset_clinical_panel()
if panel is None:
_highlight_document_metrics(self.body.document())
if report_visuals:
visual = _report_visual_widget(report_visuals, self._bubble_frame)
self._standalone_visuals.append(visual)
self._inner.insertWidget(1, visual)
return False
if report_visuals:
panel.layout().insertWidget(1, _report_visual_widget(report_visuals, panel))
panel.setParent(self._bubble_frame)
self._clinical_panel = panel
self._role_name.hide()
@@ -3043,6 +2912,11 @@ class _ChatBubble(QWidget):
return True
def _reset_clinical_panel(self) -> None:
for visual in self._standalone_visuals:
self._inner.removeWidget(visual)
visual.hide()
visual.deleteLater()
self._standalone_visuals.clear()
if self._clinical_panel is None:
return
panel = self._clinical_panel
@@ -3522,6 +3396,7 @@ class AiConsultDialog(QDialog):
self.permissions = permissions
self.diagnosis_id = 0
self.patient_id = 0
self._chart_patient_id = 0
self._seed: Mapping[str, Any] = {}
self._detail: Mapping[str, Any] = {}
self._workspace_sections: dict[str, dict[str, Any]] = {}
@@ -3547,8 +3422,6 @@ class AiConsultDialog(QDialog):
self._patient_ai_context: str = ""
self._patient_ai_context_labels: list[str] = []
self._patient_ai_context_bubble: _ChatBubble | None = None
# 全量上下文说明每轮问答都一样,只在本次会话第一次提问时提示一次。
self._context_notice_shown = False
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
self._stream_completed = False
self._follow_chat = True
@@ -3606,7 +3479,10 @@ class AiConsultDialog(QDialog):
self._session_closed = False
self.diagnosis_id = diagnosis_id
self.patient_id = _as_int(patient_id)
self._chart_patient_id = self.patient_id
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
self._detail = {}
self._workspace_sections = {}
self.source_crumb.setText(source_title)
self._generation += 1
self._image_generation += 1
@@ -3618,8 +3494,6 @@ class AiConsultDialog(QDialog):
self._patient_ai_context = ""
self._patient_ai_context_labels = []
self._patient_ai_context_bubble = None
# 换患者等于开启新会话,全量上下文说明需要再提示一次。
self._context_notice_shown = False
# Appointment seeds are presentation hints only. In the admin list,
# ``patient_id`` may actually be the diagnosis id, so it must never
# replace the separately supplied patient owner.
@@ -4137,7 +4011,7 @@ class AiConsultDialog(QDialog):
QTimer.singleShot(0, self._scroll_chat_to_bottom)
def _scroll_chat_to_bottom(self) -> None:
if not self._follow_chat:
if self._session_closed or not self._follow_chat:
return
bar = self.chat_scroll.verticalScrollBar()
bar.setValue(bar.maximum())
@@ -4173,8 +4047,15 @@ class AiConsultDialog(QDialog):
inner = QVBoxLayout(card)
inner.setContentsMargins(10, 8, 10, 8)
inner.setSpacing(2)
number = QLabel(value or "未记录")
reading = value or "未记录"
number = QLabel(reading)
number.setObjectName("AiConsultKeyValue")
number.setTextFormat(Qt.TextFormat.PlainText)
document = QTextDocument()
document.setHtml(metric_text_html(reading, metric_label=label))
foreground = document.find(reading).charFormat().foreground()
if foreground.style() != Qt.BrushStyle.NoBrush:
number.setStyleSheet(f"color: {foreground.color().name()};")
caption = QLabel(label)
caption.setObjectName("AiConsultKeyLabel")
inner.addWidget(number)
@@ -4207,6 +4088,9 @@ class AiConsultDialog(QDialog):
badge.setObjectName("AiConsultTag")
_style_surface(badge)
badge.setProperty("level", level)
color = UNKNOWN_COLOR if tag in {"加载中", "未生成", "未提供", "未记录"} else risk_color(level)
background = {ABNORMAL_COLOR: "#FFF1F3", NORMAL_COLOR: "#EAF9F3"}.get(color, "#F0F0F0")
badge.setStyleSheet(f"color: {color}; background-color: {background};")
badge.style().unpolish(badge)
badge.style().polish(badge)
header_row.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
@@ -4250,15 +4134,15 @@ class AiConsultDialog(QDialog):
text_label.setTextFormat(Qt.TextFormat.RichText)
if item.label:
html_label = _escape_html(item.label)
html_body = _escape_html(item.text or "")
html_label = metric_text_html(item.label)
html_body = metric_text_html(item.text or "", metric_label=item.label)
text_label.setText(
f"<span style='color:#1A1C1F;font-weight:600;'>{html_label}</span>"
f"<span style='color:#6A6B6D;'>:</span>"
f"<span style='color:#1A1C1F;'>{html_body}</span>"
)
else:
text_label.setText(_escape_html(item.text or ""))
text_label.setText(metric_text_html(item.text or ""))
if marker_label is not None:
layout.addWidget(marker_label, 0, Qt.AlignmentFlag.AlignTop)
@@ -4332,14 +4216,15 @@ class AiConsultDialog(QDialog):
self._set_chips(chips)
self.side_name.setText(_join_meta(name, gender, age_text) or name)
self.side_tags.setText(_join_meta(illness, "需持续随访") or "暂无诊断标签")
self._set_key_facts(
[
("病程", _course_text(*sources) or "未记录"),
("BMI", _bmi_text(*sources) or "未记录"),
("吸烟", _smoking_text(*sources) or "未记录"),
("家族史", _family_text(*sources) or "未记录"),
]
)
with metric_text_context(_metric_text_states(self._record_visuals("综合分析", ""))):
self._set_key_facts(
[
("病程", _course_text(*sources) or "未记录"),
("BMI", _bmi_text(*sources) or "未记录"),
("吸烟", _smoking_text(*sources) or "未记录"),
("家族史", _family_text(*sources) or "未记录"),
]
)
def _load_workspace(self, generation: int) -> None:
diagnosis_id = self.diagnosis_id
@@ -4722,7 +4607,29 @@ class AiConsultDialog(QDialog):
)
if item.role == "ai":
bubble.set_provenance(row)
bubble.finalize_clinical_analysis()
self._finalize_ai_bubble(bubble)
def _finalize_ai_bubble(self, bubble: _ChatBubble, *, prompt: str = "") -> None:
"""Choose useful visual evidence from the current, owned record snapshot."""
metadata = _unwrap_analysis(bubble._provenance)
if not metadata.get("risk_assessment") and isinstance(bubble._raw_payload, Mapping):
metadata = _unwrap_analysis(bubble._raw_payload)
visuals = self._record_visuals(prompt, _structured_markdown(bubble._raw_payload), metadata)
bubble.finalize_clinical_analysis(visuals=visuals)
def _record_visuals(
self, prompt: str, reply: str, metadata: Mapping[str, Any] | None = None
) -> tuple[GlucoseChartData | ReportVisual, ...]:
if self._chart_patient_id > 0 and self.patient_id != self._chart_patient_id:
return ()
sections = dict(self._workspace_sections)
if metadata and metadata.get("risk_assessment"):
sections["analysis"] = {"value": metadata}
return build_report_visuals(
prompt, reply, sections=sections, diagnosis_id=self.diagnosis_id,
patient_id=self._chart_patient_id or self.patient_id,
)
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
payload = _unwrap_analysis(analysis)
@@ -4749,14 +4656,15 @@ class AiConsultDialog(QDialog):
if markdown:
bubble = self._append_bubble("ai", markdown)
bubble.set_provenance(payload)
bubble.finalize_clinical_analysis()
self._set_insights(
[
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
]
)
self._finalize_ai_bubble(bubble)
with metric_text_context(_metric_text_states(self._record_visuals("综合分析", markdown, payload))):
self._set_insights(
[
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
]
)
@staticmethod
def _section_error(section: Mapping[str, Any]) -> str:
@@ -6029,14 +5937,6 @@ class AiConsultDialog(QDialog):
self._append_bubble(
"doctor", text, time_text=datetime.now().strftime("%H:%M")
)
if not self._context_notice_shown:
self._append_bubble(
"ai",
"服务端将按当前诊单实时附带患者全部纵向资料"
"(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
time_text="全量上下文",
)
self._context_notice_shown = True
# 首帧到达前先占位。空气泡在后端无响应时会一直是一块空白卡片,
# 医生无法判断是还在生成还是已经失败。
self._stream_bubble = self._append_bubble("ai", AI_STREAM_PENDING_TEXT)
@@ -6152,7 +6052,7 @@ class AiConsultDialog(QDialog):
self._stream_text = "未返回分析内容。"
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_provenance(self._stream_meta)
self._stream_bubble.finalize_clinical_analysis()
self._finalize_ai_bubble(self._stream_bubble, prompt=self._stream_prompt)
# A terminal event completes the UI request even if transport cleanup
# has not returned yet. The next click must not depend on finished.
self._asking = False
@@ -0,0 +1,646 @@
"""Verified measurement snapshots and native glucose charts for completed AI replies.
Charts use recorded observations, never numbers extracted from the generated advice.
"""
from __future__ import annotations
import math
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QToolTip,
QVBoxLayout,
QWidget,
)
from .ai_consult_metric_state import UNKNOWN_COLOR, metric_color
_NUMERIC = re.compile(r"(?:\d+(?:\.\d+)?|\.\d+)(?:\s*mmol\s*/\s*l)?", re.IGNORECASE)
_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
_DATETIME = re.compile(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?")
_TIME = re.compile(r"\d{2}:\d{2}(?::\d{2})?")
_MISSING = frozenset(("", "—", "--", "-", "未填写", "未记录", "暂无"))
_SERIES = (
("fasting", "空腹血糖", Qt.PenStyle.SolidLine),
("postprandial", "餐后血糖", Qt.PenStyle.DashLine),
("other", "其他血糖", Qt.PenStyle.DotLine),
)
_SERIES_KEYS = {
"fasting": ("fasting_blood_sugar", "fasting_glucose"),
"postprandial": ("postprandial_blood_sugar", "postprandial_glucose"),
"other": ("other_blood_sugar", "other_glucose", "blood_sugar"),
}
_STATUS_LABELS = {
"high": "偏高", "low": "偏低", "normal": "正常", "abnormal": "异常",
"warning": "需关注", "0": "正常", "unknown": "未标注",
}
_CARD_QSS = """
QFrame#AiConsultGlucoseReport {
background: #FFFFFF; border: 1px solid #EDEDEE; border-radius: 10px;
}
QFrame#AiConsultGlucoseReport QLabel { background: transparent; border: 0; }
QFrame#AiConsultGlucoseReport QLabel#AiConsultGlucoseTitle {
color: #1A1C1F; font-size: 15px; font-weight: 600;
}
QFrame#AiConsultGlucoseReport QLabel#AiConsultGlucoseCount,
QFrame#AiConsultGlucoseReport QLabel#AiConsultGlucoseSource {
color: #606163; font-size: 12px;
}
QFrame#AiConsultGlucoseReport QLabel#AiConsultGlucoseState {
color: #606163; font-size: 13px; padding: 9px 0;
}
QFrame#AiConsultGlucoseReport QPushButton#AiConsultGlucoseDataToggle {
color: #4F63D9; background: #F7F8FC; border: 1px solid #E4E7F4;
border-radius: 6px; padding: 5px 9px; font-size: 12px;
}
QFrame#AiConsultGlucoseReport QPushButton#AiConsultGlucoseDataToggle:hover {
background: #EEF1FA;
}
QFrame#AiConsultGlucoseReport QTableWidget#AiConsultGlucoseTable {
color: #1A1C1F; background: #FFFFFF; alternate-background-color: #F7F8FC;
border: 1px solid #EDEDEE; border-radius: 4px; gridline-color: #EDEDEE;
font-size: 12px; selection-background-color: #EEF1FA; selection-color: #1A1C1F;
}
QFrame#AiConsultGlucoseReport QHeaderView::section {
background: #F7F8FC; color: #606163; border: 0; padding: 6px 3px; font-size: 12px;
}
"""
@dataclass(frozen=True, slots=True)
class GlucoseReading:
"""One source row. Missing series stay absent, including a whole-row gap."""
recorded_at: datetime
fasting: float | None = None
postprandial: float | None = None
other: float | None = None
has_recorded_time: bool = False
fasting_status: str = ""
postprandial_status: str = ""
other_status: str = ""
@property
def readout_count(self) -> int:
return sum(value is not None for value in (self.fasting, self.postprandial, self.other))
@property
def time_label(self) -> str:
if self.has_recorded_time:
pattern = "%Y-%m-%d %H:%M:%S" if self.recorded_at.second else "%Y-%m-%d %H:%M"
return self.recorded_at.strftime(pattern)
return self.recorded_at.strftime("%Y-%m-%d")
@dataclass(frozen=True, slots=True)
class GlucoseChartData:
readings: tuple[GlucoseReading, ...] = ()
diagnosis_id: int = 0
patient_id: int = 0
excluded_count: int = 0
unavailable_reason: str = ""
@property
def available(self) -> bool:
return self.readout_count > 0
@property
def readout_count(self) -> int:
return sum(reading.readout_count for reading in self.readings)
@property
def record_count(self) -> int:
return sum(reading.readout_count > 0 for reading in self.readings)
@property
def date_range(self) -> tuple[date, date] | None:
dates = [reading.recorded_at.date() for reading in self.readings if reading.readout_count]
return (min(dates), max(dates)) if dates else None
def _exact_owner(value: Any, expected: int) -> bool:
return type(value) is int and type(expected) is int and expected > 0 and value == expected
def _patient_matches(source: Mapping[str, Any], patient_id: int) -> bool:
if "patient_id" not in source:
return True
owner = source["patient_id"]
return type(owner) is int and owner > 0 and (patient_id <= 0 or owner == patient_id)
def _first(source: Mapping[str, Any], *keys: str) -> Any:
for key in keys:
value = source.get(key)
if value is not None and value != "":
return value
return None
def _rows(value: Any) -> list[Any]:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return list(value)
return []
def _unit_matches(source: Mapping[str, Any]) -> bool:
unit = _first(source, "unit", "glucose_unit", "blood_sugar_unit")
if unit is None:
return True
return isinstance(unit, str) and re.sub(r"\s", "", unit).casefold() == "mmol/l"
def _number(value: Any) -> tuple[float | None, bool]:
"""Return (reading, ambiguous); daily-record zero is the unfilled sentinel.
DailyRecordDialog writes every spinbox, whose zero text is 未填写. The server's
BloodRecordLogic likewise includes only >0 glucose values in its trend data.
"""
if value is None:
return None, False
if isinstance(value, str):
value = value.strip()
if value in _MISSING:
return None, False
if not _NUMERIC.fullmatch(value):
return None, True
value = re.sub(r"\s*mmol\s*/\s*l$", "", value, flags=re.IGNORECASE)
elif type(value) not in (int, float):
return None, True
try:
number = float(value)
except (OverflowError, TypeError, ValueError):
return None, True
if not math.isfinite(number) or number < 0:
return None, True
return (number, False) if number > 0 else (None, False)
def _recorded_at(row: Mapping[str, Any]) -> tuple[datetime, bool] | None:
value = _first(row, "record_date", "date", "measured_at", "time_text")
if not isinstance(value, str):
return None
value = value.strip()
try:
if _DATETIME.fullmatch(value):
return datetime.fromisoformat(value), True
if not _DATE.fullmatch(value):
return None
day = date.fromisoformat(value)
clock = row.get("record_time")
if clock is None or clock == "":
# Midnight positions a date-only observation; it is never displayed as a known time.
return datetime.combine(day, time.min), False
if not isinstance(clock, str) or not _TIME.fullmatch(clock.strip()):
return None
return datetime.combine(day, time.fromisoformat(clock.strip())), True
except ValueError:
return None
def _values(row: Mapping[str, Any], *, legacy: bool) -> tuple[dict[str, float | None], bool, bool]:
raw = {field: _first(row, *keys) for field, keys in _SERIES_KEYS.items()}
if legacy and all(value is None for value in raw.values()):
period = str(_first(row, "period", "time_slot", "meal") or "").strip().casefold()
target = "other"
if period in {"空腹", "空腹血糖", "fasting"}:
target = "fasting"
elif period in {
"餐后",
"餐后血糖",
"餐后2小时",
"餐后 2 小时",
"postprandial",
"postprandial_2h",
}:
target = "postprandial"
raw[target] = row.get("value")
parsed: dict[str, float | None] = {}
ambiguous = False
for field, value in raw.items():
parsed[field], invalid = _number(value)
ambiguous = ambiguous or invalid
return parsed, ambiguous, any(value is not None for value in raw.values())
def _statuses(row: Mapping[str, Any], *, legacy: bool) -> dict[str, str]:
"""Keep only explicit scalar states on the same verified source row."""
statuses = {}
for field, keys in _SERIES_KEYS.items():
raw = _first(row, *(f"{key}_status" for key in keys))
# Generic legacy states belong only to that row's single value/period.
if legacy and raw is None and all(_first(row, *keys) is None for keys in _SERIES_KEYS.values()):
values, _ambiguous, _has_fields = _values(row, legacy=True)
if values[field] is not None:
raw = _first(row, "value_status", "glucose_status", "blood_sugar_status", "status")
statuses[f"{field}_status"] = str(raw).strip() if type(raw) in (str, int) else ""
return statuses
def build_glucose_chart_data(
tracking: Any, *, diagnosis_id: int, patient_id: int = 0
) -> GlucoseChartData:
"""Snapshot only explicitly owned, dated observations without mutating the DTO.
Production blood_records takes precedence over legacy blood_sugar.entries to
avoid double plotting a response containing two representations of the same data.
"""
empty = GlucoseChartData(diagnosis_id=diagnosis_id, patient_id=patient_id)
if type(patient_id) is not int or patient_id < 0:
return GlucoseChartData(
diagnosis_id=diagnosis_id,
patient_id=0,
unavailable_reason="缺少可核验的患者归属信息。",
)
if not isinstance(tracking, Mapping):
return GlucoseChartData(
diagnosis_id=diagnosis_id,
patient_id=patient_id,
unavailable_reason="尚未加载本诊单的血糖记录。",
)
if not _exact_owner(tracking.get("diagnosis_id"), diagnosis_id) or not _patient_matches(
tracking, patient_id
):
return GlucoseChartData(
diagnosis_id=diagnosis_id,
patient_id=patient_id,
unavailable_reason="血糖记录未通过本诊单归属校验。",
)
expected_patient = patient_id or tracking.get("patient_id", 0)
legacy = "blood_records" not in tracking
section = tracking.get("blood_sugar") if legacy else tracking
if not isinstance(section, Mapping):
return empty
if legacy and (
("diagnosis_id" in section and not _exact_owner(section["diagnosis_id"], diagnosis_id))
or not _patient_matches(section, expected_patient)
):
return GlucoseChartData(
diagnosis_id=diagnosis_id,
patient_id=patient_id,
unavailable_reason="血糖记录未通过本诊单归属校验。",
)
expected_patient = expected_patient or section.get("patient_id", 0)
rows = (
_rows(_first(section, "entries", "records", "items"))
if legacy
else _rows(section.get("blood_records"))
)
readings: list[GlucoseReading] = []
excluded = 0
for row in rows:
if (
not isinstance(row, Mapping)
or not _exact_owner(row.get("diagnosis_id"), diagnosis_id)
or not _patient_matches(row, expected_patient)
):
excluded += 1
continue
if not _unit_matches(tracking) or not _unit_matches(section) or not _unit_matches(row):
excluded += 1
continue
recorded = _recorded_at(row)
if recorded is None:
excluded += 1
continue
values, ambiguous, has_fields = _values(row, legacy=legacy)
excluded += int(ambiguous)
if not has_fields:
continue
readings.append(GlucoseReading(
recorded[0], has_recorded_time=recorded[1], **values, **_statuses(row, legacy=legacy)
))
readings.sort(key=lambda reading: reading.recorded_at) # Stable: same-time rows stay separate.
return GlucoseChartData(tuple(readings), diagnosis_id, patient_id, excluded)
def _value_text(value: float | None) -> str:
return "—" if value is None else str(value).removesuffix(".0")
def _reading_status(reading: GlucoseReading, field: str) -> str:
if getattr(reading, field) is None:
return "未记录"
status = getattr(reading, f"{field}_status")
label = _STATUS_LABELS.get(status.casefold(), status) if status else "未标注"
return f"{label}(原始:{status})" if status and label != status else label
def _draw_marker(painter: QPainter, point: QPointF, series: str) -> None:
if series in {"postprandial", "diastolic_pressure"}:
painter.drawRect(QRectF(point.x() - 3.5, point.y() - 3.5, 7, 7))
elif series == "other":
painter.drawLine(QPointF(point.x() - 4, point.y()), QPointF(point.x() + 4, point.y()))
painter.drawLine(QPointF(point.x(), point.y() - 4), QPointF(point.x(), point.y() + 4))
else:
painter.drawEllipse(point, 3.5, 3.5)
class GlucoseTrendChart(QWidget):
"""An actual-date axis, with gaps and one point for every saved observation."""
def __init__(self, data: GlucoseChartData, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.data = data
self.setObjectName("AiConsultGlucoseChart")
self.setMinimumHeight(210)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setMouseTracking(True)
self.setAccessibleName("本诊单血糖趋势图,单位 mmol/L")
description = []
for reading in data.readings:
values = ",".join(
f"{label} {_value_text(getattr(reading, field))}"
+ (" mmol/L" if getattr(reading, field) is not None else "")
+ f" · 状态 {_reading_status(reading, field)}"
for field, label, _style in _SERIES
)
if values:
description.append(f"{reading.time_label}:{values}")
self.setAccessibleDescription(
"空腹实线圆点、餐后虚线方点、其他点线十字;红色异常、绿色正常、灰色未记录/未标注。"
+ ";".join(description)
)
@staticmethod
def _point_color(reading: GlucoseReading, field: str) -> str:
return (
metric_color(getattr(reading, f"{field}_status"))
if getattr(reading, field) is not None
else UNKNOWN_COLOR
)
def sizeHint(self) -> QSize: # noqa: N802 - Qt API
return QSize(580, 220)
def _geometry(self) -> tuple[QRectF, datetime, float, float, float]:
plot = QRectF(self.rect()).adjusted(43, 32, -15, -35)
dates = [reading.recorded_at for reading in self.data.readings if reading.readout_count]
origin = min(dates) if dates else datetime.min
span = (max(dates) - origin).total_seconds() if dates else 0.0
values = [
value
for reading in self.data.readings
for value in (reading.fasting, reading.postprandial, reading.other)
if value is not None
]
lower, upper = (min(values), max(values)) if values else (0.0, 1.0)
padding = max(1.0, (upper - lower) * 0.18, upper * 0.05)
padded_upper = upper + padding
return (
plot,
origin,
span,
max(0.0, lower - padding),
padded_upper if math.isfinite(padded_upper) else upper,
)
def _position(
self,
reading: GlucoseReading,
value: float,
geometry: tuple[QRectF, datetime, float, float, float] | None = None,
) -> QPointF:
plot, origin, span, lower, upper = geometry or self._geometry()
x = (
plot.left() + plot.width() * (reading.recorded_at - origin).total_seconds() / span
if span
else plot.center().x()
)
y = plot.bottom() - plot.height() * ((value - lower) / (upper - lower))
return QPointF(x, y)
def _point_tooltip(self, position: QPointF) -> str:
hits: list[str] = []
geometry = self._geometry()
for index, reading in enumerate(self.data.readings, 1):
for field, label, _style in _SERIES:
value = getattr(reading, field)
if value is None:
continue
point = self._position(reading, value, geometry)
if (point.x() - position.x()) ** 2 + (point.y() - position.y()) ** 2 <= 100:
hits.append(
f"{reading.time_label} · 记录 {index}\n{label} {_value_text(value)} mmol/L"
f" · 状态 {_reading_status(reading, field)}"
)
return "\n\n".join(hits)
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt API
tip = self._point_tooltip(event.position())
if tip:
QToolTip.showText(event.globalPosition().toPoint(), tip, self)
else:
QToolTip.hideText()
super().mouseMoveEvent(event)
def leaveEvent(self, event: Any) -> None: # noqa: N802 - Qt API
QToolTip.hideText()
super().leaveEvent(event)
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
font = painter.font()
font.setPixelSize(11)
painter.setFont(font)
geometry = self._geometry()
plot, _origin, _span, lower, upper = geometry
if plot.width() <= 0 or plot.height() <= 0:
painter.end()
return
painter.setPen(QColor("#606163"))
painter.drawText(QRectF(0, 0, 55, 20), Qt.AlignmentFlag.AlignLeft, "mmol/L")
legend_x = 64.0
for field, label, style in _SERIES:
if not any(getattr(reading, field) is not None for reading in self.data.readings):
continue
painter.setPen(QPen(QColor(UNKNOWN_COLOR), 2, style))
painter.setBrush(QColor(UNKNOWN_COLOR))
painter.drawLine(QPointF(legend_x, 10), QPointF(legend_x + 17, 10))
_draw_marker(painter, QPointF(legend_x + 8, 10), field)
painter.setPen(QColor(UNKNOWN_COLOR))
short_label = label.replace("血糖", "")
painter.drawText(
QRectF(legend_x + 23, 0, 40, 20), Qt.AlignmentFlag.AlignLeft, short_label
)
legend_x += 64
for tick in range(4):
y = plot.bottom() - plot.height() * tick / 3
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawLine(QPointF(plot.left(), y), QPointF(plot.right(), y))
painter.setPen(QColor("#606163"))
painter.drawText(
QRectF(0, y - 8, 35, 16),
Qt.AlignmentFlag.AlignRight,
f"{lower + (upper - lower) * (tick / 3):.1f}",
)
for field, _label, style in _SERIES:
previous: QPointF | None = None
for reading in self.data.readings:
value = getattr(reading, field)
if value is None:
previous = None
continue
point = self._position(reading, value, geometry)
if previous is not None:
painter.setPen(QPen(QColor(UNKNOWN_COLOR), 2, style))
painter.drawLine(previous, point)
previous = point
# Draw status markers after all neutral line segments in this series.
for reading in self.data.readings:
value = getattr(reading, field)
if value is None:
continue
color = QColor(self._point_color(reading, field))
painter.setPen(QPen(color, 2))
painter.setBrush(color)
_draw_marker(painter, self._position(reading, value, geometry), field)
painter.setPen(QColor("#606163"))
dates = sorted(
{reading.recorded_at.date() for reading in self.data.readings if reading.readout_count}
)
if len(dates) == 1:
painter.drawText(
QRectF(plot.center().x() - 52, plot.bottom() + 9, 104, 18),
Qt.AlignmentFlag.AlignCenter,
dates[0].isoformat(),
)
elif dates:
painter.drawText(
QRectF(plot.left(), plot.bottom() + 9, 104, 18),
Qt.AlignmentFlag.AlignLeft,
dates[0].isoformat(),
)
painter.drawText(
QRectF(plot.right() - 104, plot.bottom() + 9, 104, 18),
Qt.AlignmentFlag.AlignRight,
dates[-1].isoformat(),
)
painter.end()
class GlucoseReportCard(QFrame):
def __init__(self, data: GlucoseChartData, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.data = data
self.chart: GlucoseTrendChart | None = None
self.toggle_button: QPushButton | None = None
self.setObjectName("AiConsultGlucoseReport")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
self.setStyleSheet(_CARD_QSS)
root = QVBoxLayout(self)
root.setContentsMargins(14, 12, 14, 12)
root.setSpacing(7)
title = QLabel("血糖变化趋势", self)
title.setObjectName("AiConsultGlucoseTitle")
root.addWidget(title)
self.table = QTableWidget(self)
self.table.setObjectName("AiConsultGlucoseTable")
self.table.hide()
if data.available:
count = QLabel(f"{data.readout_count} 次测量 · {data.record_count} 条记录", self)
count.setObjectName("AiConsultGlucoseCount")
root.addWidget(count)
self.chart = GlucoseTrendChart(data, self)
root.addWidget(self.chart)
if data.record_count == 1:
single = self._note("仅有 1 条记录,暂不连线。", "AiConsultGlucoseCount")
root.addWidget(single)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
self.toggle_button = QPushButton("查看原始记录", self)
self.toggle_button.setObjectName("AiConsultGlucoseDataToggle")
self.toggle_button.setCheckable(True)
self.toggle_button.setAccessibleName("查看血糖原始记录")
self.toggle_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.toggle_button.toggled.connect(self._toggle_table)
actions.addWidget(self.toggle_button)
actions.addStretch(1)
root.addLayout(actions)
self._populate_table()
else:
root.addWidget(self._note("暂无可绘制的血糖记录", "AiConsultGlucoseState"))
reason = data.unavailable_reason or "尚无带明确日期的有效血糖读数。"
root.addWidget(self._note(reason, "AiConsultGlucoseSource"))
root.addWidget(self.table)
source = f"本诊单 #{data.diagnosis_id} 的已记录数据"
if data.date_range:
start, end = data.date_range
source += f" · {start.isoformat()}" + (f" 至 {end.isoformat()}" if end != start else "")
source += ";未填写处保留空缺。红色异常、绿色正常、灰色未记录/未标注;状态以原始记录为准。"
if data.excluded_count:
source += f"\n{data.excluded_count} 条记录含未纳入的内容(归属、日期或读数无法核验)。"
root.addWidget(self._note(source, "AiConsultGlucoseSource"))
def _note(self, text: str, name: str) -> QLabel:
label = QLabel(text, self)
label.setObjectName(name)
label.setTextFormat(Qt.TextFormat.PlainText)
label.setWordWrap(True)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
return label
def _populate_table(self) -> None:
table = self.table
table.setColumnCount(4)
table.setRowCount(len(self.data.readings))
table.setHorizontalHeaderLabels(["记录日期 / 时间", "空腹", "餐后", "其他"])
table.setAccessibleName("血糖原始记录,读数单位 mmol/L")
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
table.setAlternatingRowColors(True)
table.verticalHeader().hide()
table.verticalHeader().setDefaultSectionSize(29)
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
table.horizontalHeader().setMinimumSectionSize(48)
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
table.setFixedHeight(min(224, 35 + max(1, len(self.data.readings)) * 29))
for row_index, reading in enumerate(self.data.readings):
values = [
reading.time_label,
*(_value_text(getattr(reading, field)) for field, _, _ in _SERIES),
]
for column, text in enumerate(values):
item = QTableWidgetItem(text)
if column == 0:
item.setToolTip(text)
else:
field = _SERIES[column - 1][0]
item.setForeground(QColor(GlucoseTrendChart._point_color(reading, field)))
item.setToolTip(
(f"{text} mmol/L" if text != "—" else "— · 未记录或读数不明确")
+ f" · 状态 {_reading_status(reading, field)}"
)
table.setItem(row_index, column, item)
def _toggle_table(self, checked: bool) -> None:
self.table.setVisible(checked)
if self.toggle_button is not None:
self.toggle_button.setText("收起原始记录" if checked else "查看原始记录")
self.toggle_button.setAccessibleName(
"收起血糖原始记录" if checked else "查看血糖原始记录"
)
self.updateGeometry()
@@ -0,0 +1,86 @@
"""Shared display semantics for recorded indicator states; no threshold inference."""
from __future__ import annotations
from typing import Any
ABNORMAL_COLOR = "#BD4B58"
NORMAL_COLOR = "#287B65"
UNKNOWN_COLOR = "#697386"
_ABNORMAL = frozenset(
{
"high",
"low",
"abnormal",
"warning",
"positive",
"alert",
"偏高",
"偏低",
"升高",
"降低",
"高",
"低",
"异常",
"阳性",
"警告",
"需关注",
"临界",
"超标",
"未达标",
"不达标",
"控制不佳",
"控制欠佳",
"不正常",
"非正常",
"未恢复正常",
}
)
_NORMAL = frozenset(
{
"normal",
"negative",
"正常",
"阴性",
"达标",
"已达标",
"控制良好",
"未见异常",
"无异常",
"未发现异常",
"没有异常",
"0",
}
)
def metric_state(value: Any) -> str:
"""Normalize only explicit source states; an unclassified number stays unknown."""
text = str(value).strip().casefold() if type(value) in (str, int) else ""
if text in _ABNORMAL:
return "abnormal"
if text in _NORMAL:
return "normal"
return "unknown"
def metric_color(value: Any) -> str:
return {"abnormal": ABNORMAL_COLOR, "normal": NORMAL_COLOR}.get(
metric_state(value), UNKNOWN_COLOR
)
def risk_color(value: Any) -> str:
return {
"high": ABNORMAL_COLOR,
"medium": ABNORMAL_COLOR,
"low": NORMAL_COLOR,
}.get(value if isinstance(value, str) else "", UNKNOWN_COLOR)
def combined_metric_state(values: list[Any]) -> str:
states = [metric_state(value) for value in values]
if "abnormal" in states:
return "abnormal"
return "normal" if states and all(state == "normal" for state in states) else "unknown"
@@ -0,0 +1,411 @@
"""Conservative, display-only emphasis for clinical report text.
Measurements are identified by their units or an explicit clinical label. Colors
come from matching recorded states or explicit local assertions, never numeric
thresholds. All source text remains literal HTML text.
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from html import escape
from .ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
UNKNOWN_COLOR,
metric_state,
risk_color,
)
@dataclass(frozen=True)
class MetricTextState:
"""A source field, its displayed reading (including unit), and recorded state."""
label: str
value: str
status: str
_METRIC_STATES: ContextVar[tuple[MetricTextState, ...]] = ContextVar(
"metric_text_states", default=()
)
@contextmanager
def metric_text_context(states: Sequence[MetricTextState]) -> Iterator[None]:
"""Bind trusted states for synchronous rendering and restore the outer scope."""
token = _METRIC_STATES.set(tuple(states))
try:
yield
finally:
_METRIC_STATES.reset(token)
_NUMBER = r"[+\-−]?(?:\d+(?:\.\d+)?|\.\d+)"
_COMPARATOR = r"(?:(?:<=|>=|[<>≤≥≦≧≈=<>=]|小于|大于|不超过|不低于|不高于|低于|高于|约为|约)\s*)?"
_VALUE = rf"{_COMPARATOR}{_NUMBER}(?:\s*(?:[–—~~-\-]|至|到)\s*{_NUMBER})?"
_VALUE_START = r"(?<![A-Za-z0-9_.+\-−/])"
_VALUE_END = r"(?![A-Za-z0-9_.²³/]|\s*[–—~~-\-至到]\s*\d)"
_UNIT = (
r"(?:mL\s*/\s*min(?:\s*/\s*1\.73\s*m(?:²|2|\^2))?"
r"|(?:mmol|[μµu]mol)\s*/\s*L"
r"|mmol\s*/\s*mol"
r"|mg\s*/\s*(?:dL|mmol|g(?:Cr)?|L)"
r"|[μµu]g\s*/\s*(?:g|mg|L)"
r"|(?:ng|pg)\s*/\s*mL"
r"|(?:g|IU|U|mIU|mU)\s*/\s*L"
r"|g\s*/\s*dL"
r"|[μµu]IU\s*/\s*mL"
r"|kg\s*/\s*m(?:²|2|\^2)"
r"|mmHg|bpm|次\s*/\s*(?:分钟|分|min)"
r"|kg|cm|°\s*C|℃)"
)
_UNIT_END = r"(?![A-Za-z0-9_²³/])"
_PRESSURE_VALUE = rf"{_COMPARATOR}{_NUMBER}\s*[//]\s*{_NUMBER}\s*mmHg"
_UNIT_VALUE = rf"(?:{_PRESSURE_VALUE}|{_VALUE}\s*{_UNIT}){_UNIT_END}"
_UNIT_RE = re.compile(rf"{_VALUE_START}(?P<value>{_UNIT_VALUE})", re.IGNORECASE)
# A label is necessary when a number has no unit, or directly follows an ASCII
# label (a general numeric boundary intentionally rejects IDs such as abc123).
_LABEL_GAP = r"\s*[))]?\s*(?:(?:[::=]|为|约为|是|达到|下降至|降至|上升至|升至)\s*)?"
_LABEL_START = r"(?<![A-Za-z0-9_])"
_UNIT_LABELS = (
r"(?:fasting(?:[_ ]blood[_ ]sugar|[_ ](?:blood[_ ])?glucose)?|"
r"postprandial(?:[_ ]blood[_ ](?:sugar|glucose))?|"
r"FPG|FBG|PBG|PPG|TC|TG|LDL(?:-C)?|HDL(?:-C)?|UA|Scr|Cr|"
r"UACR|ACR|ALT|AST|SBP|DBP|BP|HR|eGFR|BMI|heart_rate|height|weight)"
)
_LABELED_UNIT_RE = re.compile(
rf"{_LABEL_START}{_UNIT_LABELS}{_LABEL_GAP}(?P<value>{_UNIT_VALUE})",
re.IGNORECASE,
)
_UNITLESS_RE = re.compile(
rf"{_LABEL_START}(?:BMI|体质指数|体质量指数|体重指数|eGFR|"
rf"估算肾小球滤过率|肾小球滤过率|heart_rate|心率){_LABEL_GAP}"
rf"(?P<value>{_VALUE}){_VALUE_END}",
re.IGNORECASE,
)
_HEIGHT_RE = re.compile(
rf"{_LABEL_START}(?:height|身高){_LABEL_GAP}"
rf"(?P<value>{_VALUE}\s*(?:cm|m|厘米|米)){_UNIT_END}",
re.IGNORECASE,
)
_PERCENT_LABEL = (
r"(?:HbA1c|glycated[_ ]h(?:a)?emoglobin|糖化血红蛋白(?:水平)?|"
r"SpO2|SaO2|血氧饱和度|血氧)"
)
_LABELED_PERCENT_RE = re.compile(
rf"{_LABEL_START}{_PERCENT_LABEL}{_LABEL_GAP}"
rf"(?P<value>{_VALUE}\s*[%%]){_VALUE_END}",
re.IGNORECASE,
)
_PERCENT_RE = re.compile(rf"{_VALUE_START}(?P<value>{_VALUE}\s*[%%]){_VALUE_END}")
_PERCENT_LABEL_RE = re.compile(rf"{_LABEL_START}{_PERCENT_LABEL}", re.IGNORECASE)
_UNRELATED_PERCENT_RE = re.compile(
r"占比|比例|概率|风险|增长|下降|降低|上升|提高|提升|改善|减轻|"
r"有效率|发生率|依从|完成率|百分比|报销|费用|患者|人群"
)
_CLAUSE_BOUNDARY_RE = re.compile(r"[,,。;;!!??\n]")
_SENTENCE_BOUNDARY_RE = re.compile(r"[。;;!!??\n]")
_REFERENCE_RE = re.compile(
r"目标|参考|理想|阈值|正常(?:值|范围)|\b(?:targets?|reference|ideal|thresholds?)\b",
re.IGNORECASE,
)
_CURRENT_RE = re.compile(
r"当前|现测|本次|实测|测得|检测结果|实际|目前|现值|\b(?:current|actual|measured)\b",
re.IGNORECASE,
)
_REFERENCE_SUFFIX_RE = re.compile(
r"^\s*[((](?:目标|参考|理想|阈值|正常)(?:值|范围)?[))]"
r"|^\s*\((?:target|reference|ideal|threshold)(?:\s*(?:value|range))?\)",
re.IGNORECASE,
)
_NORMAL_STATUS = (
r"(?:未见|未发现|没有|无)(?:其他)?(?:明显|显著)?异常"
r"|(?:基本|大致)?正常|(?:已|完全)?达标|控制良好"
r"|\b(?:normal|negative)\b"
)
_ABNORMAL_STATUS = (
r"(?:尚未完全|尚未|未完全|未能|不完全|未|不)达标"
r"|不正常|非正常|未恢复正常"
r"|(?:严重|明显|显著)?异常|控制不佳|控制欠佳"
r"|\b(?:not normal|abnormal|warning|high|low|positive|alert)\b"
)
_CAUTION_STATUS = r"(?:轻度|轻微|明显|显著|略)?(?:偏高|偏低|升高|降低)|临界|超标"
_STATUS_RE = re.compile(
rf"(?P<abnormal>{_ABNORMAL_STATUS})|(?P<normal>{_NORMAL_STATUS})"
rf"|(?P<caution>{_CAUTION_STATUS})",
re.IGNORECASE,
)
_RISK_STATUS_RE = re.compile(r"高风险|中风险|低风险")
_MISSING_RE = re.compile(
r"未记录|无记录|未检测|未测量|未检查|未提供|未填写|未录入|未获取|暂无数据|"
r"暂无|缺失|未知|待测|未测|未查|未做|无数据|"
r"\b(?:not recorded|not measured|not available|unrecorded|unknown|missing|N/A)\b",
re.IGNORECASE,
)
_NON_ASSERTIVE_RE = re.compile(
r"排除|排查|除外|警惕|预防|避免|防止|谨防|建议|可能|疑似|怀疑|"
r"是否|待排|待查|如果|假如|一旦|若|如|风险|倾向|有望|争取|力争|以期|"
r"应当|应该|应使|应将|应控制|应达到|应达标|需使|需将|需达到|以达到"
r"|\b(?:possible|possibly|may|might|should|if|risk|exclude|suspected)\b",
re.IGNORECASE,
)
_NEGATED_PREFIX_RE = re.compile(
r"(?:尚未|未见|未发现|未达到|未能达到|未能|不能|无法|难以|未|没有|无|不|非)"
r"(?:明显|显著|完全|充分)?$"
)
_MISSING_PLACEHOLDER_RE = re.compile(r"\s*(?:[—–--]+|/|null|none)\s*\Z", re.IGNORECASE)
# An alias identifies a source field, never a diagnosis. More specific aliases
# win over contained words, so 空腹血糖 cannot accidentally match generic 血糖.
_FIELD_ALIASES = (
("hba1c", r"HbA1c|glycated[_ ]h(?:a)?emoglobin|糖化血红蛋白(?:水平)?"),
("fasting", r"fasting(?:[_ ]blood[_ ]sugar|[_ ](?:blood[_ ])?glucose)?|FPG|FBG|空腹血糖|空腹"),
(
"postprandial",
r"postprandial(?:[_ ]blood[_ ](?:sugar|glucose))?|PBG|PPG|餐后(?:2|两|二)?(?:小时)?血糖|餐后",
),
("random_glucose", r"random[_ ](?:blood[_ ])?(?:sugar|glucose)|随机血糖"),
("glucose", r"blood[_ ](?:sugar|glucose)|glucose|血糖"),
("blood_pressure", r"blood[_ ]pressure|BP|血压"),
("systolic", r"systolic(?:[_ ](?:blood[_ ])?pressure)?|SBP|收缩压"),
("diastolic", r"diastolic(?:[_ ](?:blood[_ ])?pressure)?|DBP|舒张压"),
("bmi", r"BMI|体质指数|体质量指数|体重指数"),
("heart_rate", r"heart[_ ]rate|HR|心率"),
("height", r"height|身高"),
("weight", r"weight|体重"),
("ldl", r"LDL(?:[-_ ]C)?|低密度脂蛋白(?:胆固醇)?"),
("hdl", r"HDL(?:[-_ ]C)?|高密度脂蛋白(?:胆固醇)?"),
("cholesterol", r"TC|total[_ ]cholesterol|总胆固醇"),
("triglycerides", r"TG|triglycerides?|甘油三酯"),
("egfr", r"eGFR|估算肾小球滤过率|肾小球滤过率"),
("uacr", r"UACR|ACR|尿白蛋白(?:与)?肌酐比(?:值)?"),
("creatinine", r"Scr|Cr|creatinine|血(?:清)?肌酐|肌酐"),
("uric_acid", r"UA|uric[_ ]acid|尿酸"),
("alt", r"ALT|丙氨酸氨基转移酶|谷丙转氨酶"),
("ast", r"AST|天门冬氨酸氨基转移酶|谷草转氨酶"),
("spo2", r"SpO2|SaO2|血氧饱和度|血氧"),
("temperature", r"temperature|体温"),
("hemoglobin", r"h(?:a)?emoglobin|血红蛋白"),
)
_FIELD_PATTERNS = tuple(
(field, re.compile(rf"{_LABEL_START}(?:{aliases})(?![A-Za-z_])", re.IGNORECASE))
for field, aliases in _FIELD_ALIASES
)
_STANDALONE_VALUE_RE = re.compile(rf"\s*(?P<value>{_VALUE}(?:\s*[%%])?)\s*\Z")
def _display_key(value: str) -> str:
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", value)).casefold()
def _field_mentions(text: str) -> list[tuple[int, int, str]]:
mentions = [
(match.start(), match.end(), field)
for field, pattern in _FIELD_PATTERNS
for match in pattern.finditer(text)
]
# Unknown source labels can still bind to an exact label in the rendered text.
for state in _METRIC_STATES.get():
label = state.label.strip()
if not label or any(pattern.search(label) for _, pattern in _FIELD_PATTERNS):
continue
pattern = re.compile(rf"{_LABEL_START}{re.escape(label)}(?![A-Za-z_])", re.IGNORECASE)
mentions.extend((m.start(), m.end(), _display_key(label)) for m in pattern.finditer(text))
mentions.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int, str]] = []
for mention in mentions:
if not selected or mention[0] >= selected[-1][1]:
selected.append(mention)
return selected
def _field_key(label: str) -> str:
mentions = _field_mentions(label)
return mentions[-1][2] if mentions else _display_key(label)
def _reading_field(text: str, start: int, metric_label: str) -> str:
_, clause_start = _clause(text, start)
mentions = _field_mentions(text[clause_start:start])
return mentions[-1][2] if mentions else _field_key(metric_label)
def _recorded_color(text: str, start: int, end: int, metric_label: str) -> str | None:
field = _reading_field(text, start, metric_label)
if not field:
return None
reading = _display_key(text[start:end])
states = {
metric_state(state.status)
for state in _METRIC_STATES.get()
if _field_key(state.label) == field and _display_key(state.value) == reading
}
if not states:
return None
if len(states) != 1:
return UNKNOWN_COLOR
return {"normal": NORMAL_COLOR, "abnormal": ABNORMAL_COLOR}.get(states.pop(), UNKNOWN_COLOR)
def _clause(text: str, position: int) -> tuple[str, int]:
start = 0
for boundary in _CLAUSE_BOUNDARY_RE.finditer(text, 0, position):
start = boundary.end()
boundary = _CLAUSE_BOUNDARY_RE.search(text, position)
end = boundary.start() if boundary else len(text)
return text[start:end], start
def _is_reference(text: str, start: int, end: int) -> bool:
clause, clause_start = _clause(text, start)
prefix = clause[: start - clause_start][-64:]
references = list(_REFERENCE_RE.finditer(prefix))
current = list(_CURRENT_RE.finditer(prefix))
if references and (not current or references[-1].start() > current[-1].start()):
return True
return bool(_REFERENCE_SUFFIX_RE.match(text[end:]))
def _is_reference_percent(text: str, start: int, end: int) -> bool:
if not _is_reference(text, start, end):
return False
sentence_start = 0
for boundary in _SENTENCE_BOUNDARY_RE.finditer(text, 0, start):
sentence_start = boundary.end()
prefix = text[max(sentence_start, start - 100) : start]
labels = list(_PERCENT_LABEL_RE.finditer(prefix))
if not labels:
return False
return not _UNRELATED_PERCENT_RE.search(prefix[labels[-1].end() :])
def _status_color(text: str, match: re.Match[str]) -> str | None:
clause, clause_start = _clause(text, match.start())
if _NON_ASSERTIVE_RE.search(clause):
return None
prefix = text[max(clause_start, match.start() - 12) : match.start()]
if _NEGATED_PREFIX_RE.search(prefix):
return None
if text[match.end() :].startswith(("率", "值", "范围", "标准", "目标", "阈值")):
return None
if match.lastgroup == "normal":
return NORMAL_COLOR
return ABNORMAL_COLOR
def _local_color(
text: str,
start: int,
end: int,
readings: Sequence[tuple[int, int]],
statuses: Sequence[tuple[re.Match[str], str]],
metric_label: str,
) -> str:
clause, clause_start = _clause(text, start)
field = _reading_field(text, start, metric_label)
colors: set[str] = set()
for status, color in statuses:
if not clause_start <= status.start() < clause_start + len(clause):
continue
left, right = (end, status.start()) if status.start() >= end else (status.end(), start)
if right < left or right - left > 32:
continue
if any(left <= other_start < right for other_start, _ in readings):
continue
# A status belonging to a new field in the same clause must not leak.
mentions = _field_mentions(text[left:right])
if any(other_field != field for _, _, other_field in mentions):
continue
colors.add(color)
return colors.pop() if len(colors) == 1 else UNKNOWN_COLOR
def metric_text_html(text: str, *, metric_label: str = "") -> str:
"""Return escaped, whitespace-preserving HTML with conservative emphasis.
Clinical values retain their original range, comparator, unit and spelling.
A bound state requires the same field alias and displayed reading, including
comparator, range and unit. Without a matching record only an asserted local
status can classify a reading. References and unclassified values stay gray.
Newlines follow Qt plain text; incoming HTML is always shown literally.
"""
text = text.replace("\r\n", "\n").replace("\r", "\n")
spans: list[tuple[int, int, str]] = []
candidates: set[tuple[int, int]] = set()
for pattern in (_UNIT_RE, _LABELED_UNIT_RE, _UNITLESS_RE, _HEIGHT_RE, _LABELED_PERCENT_RE):
for match in pattern.finditer(text):
candidates.add(match.span("value"))
for match in _PERCENT_RE.finditer(text):
start, end = match.span("value")
clause, clause_start = _clause(text, start)
hinted_percent = _field_key(metric_label) in {
"hba1c",
"spo2",
} and not _UNRELATED_PERCENT_RE.search(clause[: start - clause_start])
if hinted_percent or _is_reference_percent(text, start, end):
candidates.add((start, end))
if metric_label and (standalone := _STANDALONE_VALUE_RE.fullmatch(text)):
candidates.add(standalone.span("value"))
readings: list[tuple[int, int]] = []
for start, end in sorted(candidates, key=lambda span: (span[0], -span[1])):
if not readings or start >= readings[-1][1]:
readings.append((start, end))
statuses = [
(match, color)
for match in _STATUS_RE.finditer(text)
if (color := _status_color(text, match))
]
for start, end in readings:
if _is_reference(text, start, end) or _REFERENCE_RE.search(metric_label):
color = UNKNOWN_COLOR
else:
color = _recorded_color(text, start, end, metric_label)
if color is None:
color = _local_color(text, start, end, readings, statuses, metric_label)
spans.append((start, end, color))
spans.extend((match.start(), match.end(), color) for match, color in statuses)
for match in _RISK_STATUS_RE.finditer(text):
clause, clause_start = _clause(text, match.start())
prefix = text[max(clause_start, match.start() - 12) : match.start()]
if (
_NON_ASSERTIVE_RE.search(clause.replace("风险", ""))
or _NEGATED_PREFIX_RE.search(prefix)
or _REFERENCE_RE.search(clause)
):
continue
level = {"高风险": "high", "中风险": "medium", "低风险": "low"}[match.group()]
spans.append((match.start(), match.end(), risk_color(level)))
spans.extend(
(match.start(), match.end(), UNKNOWN_COLOR) for match in _MISSING_RE.finditer(text)
)
if metric_label and _MISSING_PLACEHOLDER_RE.fullmatch(text):
spans.append((0, len(text), UNKNOWN_COLOR))
# Prefer a full reading over a contained unitless value, then consume each
# original character once. Generated markup is never scanned by a regex.
spans.sort(key=lambda span: (span[0], -(span[1] - span[0])))
pieces = ['<span style="white-space: pre-wrap;">']
position = 0
for start, end, color in spans:
if start < position:
continue
pieces.append(escape(text[position:start]))
pieces.append(
f'<span style="color: {color}; font-weight: 600;">{escape(text[start:end])}</span>'
)
position = end
pieces.append(escape(text[position:]))
pieces.append("</span>")
return "".join(pieces).replace("\n", "<br>")
@@ -0,0 +1,953 @@
"""Content-selected native report visuals backed only by owned source records.
Generated prose selects topics; it never supplies numeric observations, dates,
reference limits, or risk levels. All render data is an immutable snapshot.
"""
from __future__ import annotations
import math
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, fields, is_dataclass
from datetime import datetime
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHeaderView,
QLabel,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QToolTip,
QVBoxLayout,
QWidget,
)
from .ai_consult_charts import (
GlucoseChartData,
GlucoseReportCard,
_draw_marker,
_recorded_at,
build_glucose_chart_data,
)
from .ai_consult_metric_state import UNKNOWN_COLOR, metric_color, risk_color
_NUMBER = r"(?:\d+(?:\.\d+)?|\.\d+)"
_RANGE = re.compile(rf"({_NUMBER})\s*[-–—~~至]\s*({_NUMBER})")
_TOPICS = {
"glucose": r"血糖|糖尿病|糖化|glucose|glyc[ae]m|hba1c|\ba1c\b",
"blood_pressure": r"血压|收缩压|舒张压|blood\s*pressure|hypertension|hypotension",
"lipids": r"血脂|胆固醇|甘油三酯|脂蛋白|lipid|cholesterol|triglyceride|\b[hl]dl\b",
"renal": r"肾|肌酐|尿素|尿酸|尿微量白蛋白|renal|kidney|creatinine|egfr|albumin",
"weight": r"体重|肥胖|减重|体质指数|weight|\bbmi\b|obesity",
"risk": r"风险|并发症|risk|complication",
"medication": r"用药|药物|处方|服药|药方|治疗方案|medication|prescription|\bdrug",
"activity": r"运动|活动|锻炼|exercise|activity|walking",
}
_OVERVIEW = re.compile(
r"综合|全面|总体|健康概览|病情总结|总结.*病情|病情.*总结|overall|comprehensive", re.I
)
_NEUTRAL = UNKNOWN_COLOR
_RISK_LABELS = {"low": "低风险", "medium": "中风险", "high": "高风险", "unknown": "未知"}
_STATUS_LABELS = {
"high": "偏高", "low": "偏低", "normal": "正常", "abnormal": "异常",
"warning": "需关注", "0": "正常", "unknown": "未标注",
}
def _status_label(item: VisualDatum) -> str:
if item.raw_value == "—":
return "未记录"
labels = _RISK_LABELS if item.raw_value == _RISK_LABELS.get(item.status) else _STATUS_LABELS
return labels.get(item.status.casefold(), item.status) or "未标注"
@dataclass(frozen=True, slots=True)
class VisualDatum:
label: str
raw_value: str
value: float | None = None
unit: str = ""
recorded_at: datetime | None = None
time_label: str = ""
series: str = ""
status: str = ""
color: str = _NEUTRAL
interval: tuple[float, float] | None = None
reference: tuple[float, float] | None = None
@property
def value_text(self) -> str:
return self.raw_value + (f" {self.unit}" if self.unit and self.raw_value != "—" else "")
@dataclass(frozen=True, slots=True)
class ReportVisual:
kind: str
title: str
items: tuple[VisualDatum, ...]
source: str
diagnosis_id: int
patient_id: int = 0
# Defaults are the units used by the existing diagnosis / reception fields.
# A declared source unit always takes precedence, without conversion.
_METRICS = (
("glucose", "空腹血糖", ("fasting_blood_sugar", "fasting_glucose"), "mmol/L"),
("glucose", "餐后血糖", ("postprandial_blood_sugar", "postprandial_glucose"), "mmol/L"),
("glucose", "糖化血红蛋白", ("glycated_hemoglobin", "hba1c", "glycosylated_hemoglobin"), "%"),
("blood_pressure", "收缩压", ("systolic_pressure", "systolic"), "mmHg"),
("blood_pressure", "舒张压", ("diastolic_pressure", "diastolic"), "mmHg"),
("lipids", "总胆固醇", ("total_cholesterol", "cholesterol"), "mmol/L"),
("lipids", "甘油三酯", ("triglycerides", "triglyceride"), "mmol/L"),
("lipids", "高密度脂蛋白", ("hdl_c", "hdl", "high_density_lipoprotein"), "mmol/L"),
("lipids", "低密度脂蛋白", ("ldl_c", "ldl", "low_density_lipoprotein"), "mmol/L"),
("renal", "尿微量白蛋白", ("urine_microalbumin", "microalbumin", "urinary_albumin"), "mg/L"),
("renal", "肌酐", ("creatinine", "serum_creatinine"), "μmol/L"),
("renal", "尿素氮", ("blood_urea_nitrogen", "bun"), "mmol/L"),
("renal", "尿酸", ("uric_acid",), "μmol/L"),
("renal", "估算肾小球滤过率", ("egfr",), "mL/min/1.73m²"),
("weight", "体重", ("weight",), "kg"),
("weight", "身高", ("height",), "cm"),
("weight", "BMI", ("bmi",), "kg/m²"),
)
def _mapping(value: Any) -> Mapping[str, Any]:
if isinstance(value, Mapping):
return value
if is_dataclass(value) and not isinstance(value, type):
result = {field.name: getattr(value, field.name) for field in fields(value)}
raw = result.pop("raw", None)
if isinstance(raw, Mapping):
# Preserve original ownership values rather than trusting DTO coercions.
result.update(raw)
return result
return {}
def _rows(value: Any) -> tuple[Any, ...]:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return tuple(value)
return ()
def _first(source: Mapping[str, Any], *keys: str) -> Any:
return next((source[key] for key in keys if source.get(key) not in (None, "")), None)
def _exact(value: Any, expected: int) -> bool:
return type(value) is int and type(expected) is int and expected > 0 and value == expected
def _patient_matches(source: Mapping[str, Any], expected: int) -> bool:
if "patient_id" not in source:
return True
owner = source["patient_id"]
return type(owner) is int and owner > 0 and (expected == 0 or owner == expected)
def _owned(source: Mapping[str, Any], diagnosis_id: int, patient_id: int) -> bool:
return _exact(source.get("diagnosis_id"), diagnosis_id) and _patient_matches(source, patient_id)
def _declared_owners_match(source: Mapping[str, Any], diagnosis_id: int, patient_id: int) -> bool:
return (
"diagnosis_id" not in source or _exact(source["diagnosis_id"], diagnosis_id)
) and _patient_matches(source, patient_id)
def _section(sections: Mapping[str, Any], key: str) -> Any:
section = _mapping(sections.get(key))
return None if section.get("error") else section.get("value")
def _numeric(value: Any, unit: str = "", *, allow_zero: bool = False) -> float | None:
if isinstance(value, str):
value = value.strip()
suffix = rf"(?:\s*{re.escape(unit)})?" if unit else ""
match = re.fullmatch(rf"({_NUMBER}){suffix}", value, re.I)
if match is None:
return None
value = match.group(1)
elif type(value) not in (int, float):
return None
try:
number = float(value)
except (ValueError, TypeError, OverflowError):
return None
# Zero is the unfilled spinbox sentinel for these existing clinical fields.
return number if math.isfinite(number) and (number > 0 or allow_zero and number == 0) else None
def _interval(value: Any, unit: str) -> tuple[float, float] | None:
if not isinstance(value, str):
return None
text = value.strip()
if unit and text.casefold().endswith(unit.casefold()):
text = text[: -len(unit)].strip()
match = _RANGE.fullmatch(text)
if match:
low, high = (
_numeric(match.group(1), allow_zero=True),
_numeric(match.group(2), allow_zero=True),
)
if low is not None and high is not None and low < high:
return low, high
return None
def _number_text(value: float) -> str:
return str(value).removesuffix(".0")
def _plain(value: Any) -> str:
return str(value).strip() if type(value) in (str, int, float) else ""
def _reference(
source: Mapping[str, Any], keys: tuple[str, ...], unit: str
) -> tuple[float, float] | None:
for key in keys:
value = source.get(f"{key}_reference_range")
if isinstance(value, Mapping):
ref_unit = value.get("unit", unit)
low, high = (
_numeric(value.get("low"), unit, allow_zero=True),
_numeric(value.get("high"), unit, allow_zero=True),
)
if ref_unit == unit and low is not None and high is not None and low < high:
return low, high
elif (interval := _interval(value, unit)) is not None:
return interval
low = _numeric(source.get(f"{key}_reference_low"), unit, allow_zero=True)
high = _numeric(source.get(f"{key}_reference_high"), unit, allow_zero=True)
if low is not None and high is not None and low < high:
return low, high
return None
def _snapshot_items(
detail: Mapping[str, Any],
topics: set[str],
diagnosis_id: int,
patient_id: int,
*,
glucose_trend: bool,
pressure_trend: bool,
) -> tuple[VisualDatum, ...]:
diagnosis = _mapping(detail.get("diagnosis"))
sources = [diagnosis]
for base in (diagnosis, detail):
for key in ("vitals", "lab_results", "labs"):
source = _mapping(base.get(key))
if source and _declared_owners_match(source, diagnosis_id, patient_id):
sources.append(source)
result = []
for topic, label, keys, default_unit in _METRICS:
if topic not in topics:
continue
if pressure_trend and topic == "blood_pressure":
continue
if glucose_trend and keys[0] in {"fasting_blood_sugar", "postprandial_blood_sugar"}:
continue
for source in sources:
raw = _first(source, *keys)
if raw is None:
continue
unit = _plain(_first(source, *(f"{key}_unit" for key in keys))) or default_unit
value, interval = _numeric(raw, unit), _interval(raw, unit)
if value is None and interval is None:
break
status = _plain(_first(source, *(f"{key}_status" for key in keys)))
# Reception also uses hdl_status / ldl_status for *_c aliases.
if not status and keys[0] in {"hdl_c", "ldl_c"}:
status = _plain(source.get(f"{keys[1]}_status"))
raw_text = _number_text(value) if value is not None else _plain(raw)
if interval and unit and raw_text.casefold().endswith(unit.casefold()):
raw_text = raw_text[: -len(unit)].strip()
result.append(
VisualDatum(
label,
raw_text,
value,
unit,
status=status,
color=metric_color(status),
interval=interval,
reference=_reference(source, keys, unit),
)
)
break
# The detail loader has already verified this patient's identity. Body
# measurements may live in patient rather than in the visit's diagnosis.
patient = _mapping(detail.get("patient"))
existing = {item.label for item in result}
if "weight" in topics and _patient_matches(patient, patient_id):
for _topic, label, keys, default_unit in _METRICS:
if _topic != "weight" or label in existing:
continue
unit = _plain(_first(patient, *(f"{key}_unit" for key in keys))) or default_unit
value = _numeric(_first(patient, *keys), unit)
if value is not None:
status = _plain(_first(patient, *(f"{key}_status" for key in keys)))
result.append(VisualDatum(
label, _number_text(value), value, unit, status=status,
color=metric_color(status),
))
return tuple(result)
def _tracking_visual(
tracking: Any,
*,
diagnosis_id: int,
patient_id: int,
activity: bool = False,
) -> ReportVisual | None:
source = _mapping(tracking)
if not _owned(source, diagnosis_id, patient_id):
return None
patient_id = patient_id or source.get("patient_id", 0)
items = []
definitions = (
((("duration",), "运动时长"),)
if activity
else (
(("systolic_pressure", "systolic"), "收缩压"),
(("diastolic_pressure", "diastolic"), "舒张压"),
)
)
unit = "分钟" if activity else "mmHg"
unit_keys = (
("duration_unit", "exercise_unit") if activity else ("pressure_unit", "blood_pressure_unit")
)
for raw in _rows(source.get("exercise_records" if activity else "blood_records")):
row = _mapping(raw)
if not _owned(row, diagnosis_id, patient_id) or (recorded := _recorded_at(row)) is None:
continue
declared_unit = _first(row, *unit_keys)
root_unit = _first(source, *unit_keys)
if any(value is not None and value != unit for value in (declared_unit, root_unit)):
continue
timestamp, has_time = recorded
time_label = (
timestamp.strftime("%Y-%m-%d %H:%M:%S" if timestamp.second else "%Y-%m-%d %H:%M")
if has_time
else timestamp.date().isoformat()
)
for keys, label in definitions:
value = _numeric(_first(row, *keys), unit)
status = _plain(_first(row, *(f"{key}_status" for key in keys)))
items.append(
VisualDatum(
label,
_number_text(value) if value is not None else "—",
value,
unit,
timestamp,
time_label,
keys[0],
status=status,
color=metric_color(status) if value is not None else UNKNOWN_COLOR,
)
)
if not any(item.value is not None for item in items):
return None
items.sort(key=lambda item: item.recorded_at or datetime.min)
title = "运动时长记录" if activity else "血压变化趋势"
return ReportVisual(
"exercise" if activity else "blood_pressure",
title,
tuple(items),
f"本诊单 #{diagnosis_id} 的已记录数据 · {unit};按实际记录日期排列。"
"红色异常、绿色正常、灰色未记录/未标注;状态以原始记录为准。",
diagnosis_id,
patient_id,
)
def _analysis_payload(raw: Any, diagnosis_id: int, patient_id: int) -> Mapping[str, Any]:
"""Unwrap one saved report, carrying its ownership through every envelope."""
current = _mapping(raw)
result: dict[str, Any] = {}
for _depth in range(5):
if not current or not _patient_matches(current, patient_id):
return {}
declared = current.get("diagnosis_id")
source_ids = current.get("source_diagnosis_ids", result.get("source_diagnosis_ids"))
# A patient report may cover several visits. Its explicit, exact source
# IDs can establish report ownership but never measurement ownership.
longitudinal = (
patient_id > 0
and _exact(current.get("patient_id", result.get("patient_id")), patient_id)
and diagnosis_id in _rows(source_ids)
and all(type(item) is int and item > 0 for item in _rows(source_ids))
)
if (
"diagnosis_id" in current
and not _exact(declared, diagnosis_id)
and not (longitudinal and type(declared) is int and declared > 0)
):
return {}
result.update(current)
if isinstance(current.get("report"), Mapping):
current = current["report"]
elif isinstance(current.get("latest_by_model"), Mapping):
latest = current["latest_by_model"]
current = next(
(
_mapping(latest.get(key))
for key in ("qwen", "openai")
if _mapping(latest.get(key))
),
{},
)
elif _rows(current.get("reports")):
current = _mapping(_rows(current["reports"])[0])
else:
break
source_ids = _rows(result.get("source_diagnosis_ids"))
owned = _exact(result.get("diagnosis_id"), diagnosis_id) or (
patient_id > 0
and _exact(result.get("patient_id"), patient_id)
and diagnosis_id in source_ids
and all(type(item) is int and item > 0 for item in source_ids)
)
return result if owned else {}
def _risk_visual(raw: Any, diagnosis_id: int, patient_id: int) -> ReportVisual | None:
analysis = _analysis_payload(raw, diagnosis_id, patient_id)
items = []
for raw_row in _rows(analysis.get("risk_assessment")):
row = _mapping(raw_row)
label, level = _plain(row.get("label")), row.get("level")
if not label or not _declared_owners_match(row, diagnosis_id, patient_id):
continue
if not isinstance(level, str) or level not in _RISK_LABELS:
continue
items.append(
VisualDatum(label, _RISK_LABELS[level], status=level, color=risk_color(level))
)
if not items:
return None
source_ids = _rows(analysis.get("source_diagnosis_ids"))
visits = "、".join(f"#{item}" for item in source_ids if type(item) is int and item > 0)
source = "AI 报告的定性风险标注 · 保留报告原始等级"
if visits:
source += f" · 报告来源诊单 {visits}"
return ReportVisual("risks", "风险分层", tuple(items), source, diagnosis_id, patient_id)
def _medication_visual(raw: Any, diagnosis_id: int, patient_id: int) -> ReportVisual | None:
if isinstance(raw, Mapping):
if not _owned(raw, diagnosis_id, patient_id):
return None
patient_id = patient_id or raw.get("patient_id", 0)
rows = _rows(_first(raw, "items", "records", "lists"))
else:
rows = _rows(raw)
items = []
for raw_row in rows:
row = _mapping(raw_row)
if not _owned(row, diagnosis_id, patient_id):
continue
name = _plain(_first(row, "prescription_name", "prescription_summary", "summary", "name"))
if not name:
names = [_plain(_mapping(herb).get("name")) for herb in _rows(row.get("herbs"))]
name = "、".join(value for value in names if value)
recorded = _recorded_at({"date": _first(row, "prescription_date", "create_time")})
status = _plain(_first(row, "status_text", "audit_status_text"))
if not status:
audit = row.get("audit_status")
status = (
{0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit, "") if type(audit) is int else ""
)
if type(row.get("void_status")) is int and row["void_status"] == 1:
status = f"{status} · 已作废" if status else "已作废"
if not name or recorded is None or not status:
continue
timestamp, has_time = recorded
time_label = (
timestamp.isoformat(sep=" ", timespec="seconds" if timestamp.second else "minutes")
if has_time
else timestamp.date().isoformat()
)
items.append(
VisualDatum(name, status, recorded_at=timestamp, time_label=time_label, status=status)
)
if not items:
return None
items.sort(key=lambda item: item.recorded_at or datetime.min)
return ReportVisual(
"medications",
"处方记录时间线",
tuple(items),
f"本诊单 #{diagnosis_id} 的处方开具事件 · 日期、方名和状态来自原始处方。",
diagnosis_id,
patient_id,
)
def build_report_visuals(
prompt: str,
reply: str,
*,
sections: Mapping[str, Any],
diagnosis_id: int,
patient_id: int = 0,
) -> tuple[GlucoseChartData | ReportVisual, ...]:
"""Select only relevant, usable source visuals. Empty input makes no shell."""
if (
type(diagnosis_id) is not int
or diagnosis_id <= 0
or type(patient_id) is not int
or patient_id < 0
):
return ()
if not isinstance(sections, Mapping) or not _declared_owners_match(
sections, diagnosis_id, patient_id
):
return ()
detail = _mapping(_section(sections, "detail"))
if detail:
diagnosis = _mapping(detail.get("diagnosis"))
if (
not _exact(diagnosis.get("id"), diagnosis_id)
or not _declared_owners_match(diagnosis, diagnosis_id, patient_id)
or not _declared_owners_match(detail, diagnosis_id, patient_id)
):
return ()
patient = _mapping(detail.get("patient"))
patient_id = patient_id or diagnosis.get("patient_id", 0) or detail.get("patient_id", 0)
if "id" in patient and not _exact(patient["id"], patient_id or patient["id"]):
return ()
patient_id = patient_id or patient.get("id", 0)
if not all(_patient_matches(source, patient_id) for source in (detail, diagnosis, patient)):
return ()
text = f"{prompt}\n{reply}"
topics = (
set(_TOPICS)
if _OVERVIEW.search(text)
else {topic for topic, pattern in _TOPICS.items() if re.search(pattern, text, re.I)}
)
result: list[GlucoseChartData | ReportVisual] = []
tracking = _section(sections, "tracking")
if "glucose" in topics:
glucose = build_glucose_chart_data(
tracking, diagnosis_id=diagnosis_id, patient_id=patient_id
)
if glucose.available:
result.append(glucose)
pressure = (
_tracking_visual(tracking, diagnosis_id=diagnosis_id, patient_id=patient_id)
if "blood_pressure" in topics
else None
)
if pressure:
result.append(pressure)
metrics = _snapshot_items(
detail,
topics,
diagnosis_id,
patient_id,
glucose_trend=any(isinstance(item, GlucoseChartData) for item in result),
pressure_trend=pressure is not None,
)
if metrics:
result.append(
ReportVisual(
"metrics",
"相关指标快照",
metrics,
f"本诊单 #{diagnosis_id} 的病历记录 · 红色异常、绿色正常、灰色未记录/未标注;各指标独立展示。",
diagnosis_id,
patient_id,
)
)
if "risk" in topics and (
risk := _risk_visual(_section(sections, "analysis"), diagnosis_id, patient_id)
):
result.append(risk)
if "medication" in topics and (
medication := _medication_visual(
_section(sections, "prescriptions"), diagnosis_id, patient_id
)
):
result.append(medication)
if "activity" in topics and (
activity := _tracking_visual(
tracking, diagnosis_id=diagnosis_id, patient_id=patient_id, activity=True
)
):
result.append(activity)
return tuple(result)
class ReportPlot(QWidget):
"""Small native charts with exact record tooltips and accessible readouts."""
def __init__(self, data: ReportVisual, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.data = data
self.setObjectName("AiConsultReportPlot")
self.setMouseTracking(True)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setMinimumWidth(0)
self.setFixedHeight(214 if self._trend else len(data.items) * self._row_height)
self.setAccessibleName(data.title)
legend = "红色异常、绿色正常、灰色未记录/未标注。"
if data.kind == "blood_pressure":
legend += "收缩压实线圆点,舒张压虚线方点。"
self.setAccessibleDescription(legend + ";".join(self._readout(item) for item in data.items))
@property
def _trend(self) -> bool:
return self.data.kind in {"blood_pressure", "exercise"}
@property
def _row_height(self) -> int:
return 88 if self.data.kind == "metrics" else 78
@property
def _columns(self) -> int:
return 2 if self.data.kind == "metrics" and self.width() >= 520 else 1
def resizeEvent(self, event: Any) -> None: # noqa: N802
super().resizeEvent(event)
if self.data.kind == "metrics":
rows = math.ceil(len(self.data.items) / self._columns)
height = rows * self._row_height
if self.height() != height:
self.setFixedHeight(height)
def sizeHint(self) -> QSize: # noqa: N802
return QSize(480, self.height())
def minimumSizeHint(self) -> QSize: # noqa: N802
return QSize(0, self.height())
@staticmethod
def _readout(item: VisualDatum) -> str:
text = " · ".join(part for part in (item.time_label, item.label, item.value_text) if part)
if item.interval:
text += "(原始记录区间)"
label = _status_label(item)
text += f" · 状态 {label}"
if item.status and label != item.status:
text += f"(原始:{item.status})"
if item.reference:
text += f" · 记录参考区间 {_number_text(item.reference[0])}–{_number_text(item.reference[1])} {item.unit}"
return text
def _geometry(self) -> tuple[QRectF, datetime, float, float, float]:
items = [item for item in self.data.items if item.value is not None and item.recorded_at]
origin = min(item.recorded_at for item in items)
span = (max(item.recorded_at for item in items) - origin).total_seconds()
low, high = min(item.value for item in items), max(item.value for item in items)
# Normalize first to keep finite, even for malformed but finite huge inputs.
scale = max(high, 1.0)
low, high = low / scale, high / scale
pad = max((high - low) * 0.18, 0.05)
upper = (high + pad) * scale
return (
QRectF(self.rect()).adjusted(43, 33, -12, -35),
origin,
span,
max(0, low - pad) * scale,
upper if math.isfinite(upper) else high * scale,
)
def _position(self, item: VisualDatum) -> QPointF:
plot, origin, span, low, high = self._geometry()
x = (
plot.left() + plot.width() * (item.recorded_at - origin).total_seconds() / span
if span
else plot.center().x()
)
scale = max(high, 1.0)
y = plot.bottom() - plot.height() * (
(item.value / scale - low / scale) / (high / scale - low / scale)
)
return QPointF(x, y)
def _point_tooltip(self, position: QPointF) -> str:
if self._trend:
return "\n\n".join(
self._readout(item)
for item in self.data.items
if item.value is not None
and (self._position(item) - position).manhattanLength() < 12
)
column_width = (self.width() + 24) / self._columns
column = min(self._columns - 1, max(0, int(position.x() // column_width)))
index = int(position.y() // self._row_height) * self._columns + column
return self._readout(self.data.items[index]) if 0 <= index < len(self.data.items) else ""
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
if tip := self._point_tooltip(event.position()):
QToolTip.showText(event.globalPosition().toPoint(), tip, self)
else:
QToolTip.hideText()
super().mouseMoveEvent(event)
def leaveEvent(self, event: Any) -> None: # noqa: N802
QToolTip.hideText()
super().leaveEvent(event)
def _text(
self, painter: QPainter, rect: QRectF, text: str, *, color: str = _NEUTRAL, size: int = 12
) -> None:
font = painter.font()
font.setPixelSize(size)
painter.setFont(font)
painter.setPen(QColor(color))
painter.drawText(
rect,
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
painter.fontMetrics().elidedText(
text, Qt.TextElideMode.ElideRight, max(0, int(rect.width()))
),
)
def _paint_trend(self, painter: QPainter) -> None:
plot, origin, span, low, high = self._geometry()
self._text(painter, QRectF(0, 0, 65, 22), self.data.items[0].unit, size=11)
series = tuple(dict.fromkeys(item.series for item in self.data.items))
for index, key in enumerate(series):
item = next(item for item in self.data.items if item.series == key)
style = Qt.PenStyle.DashLine if key == "diastolic_pressure" else Qt.PenStyle.SolidLine
left = 65 + index * 100
painter.setPen(QPen(QColor(_NEUTRAL), 2, style))
painter.setBrush(QColor(_NEUTRAL))
painter.drawLine(QPointF(left, 11), QPointF(left + 17, 11))
_draw_marker(painter, QPointF(left + 8, 11), key)
self._text(
painter,
QRectF(left + 23, 0, 76, 22),
item.label,
size=11,
)
for tick in range(4):
y = plot.bottom() - plot.height() * tick / 3
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawLine(QPointF(plot.left(), y), QPointF(plot.right(), y))
self._text(
painter,
QRectF(0, y - 9, 40, 18),
f"{low * (1 - tick / 3) + high * tick / 3:.0f}",
size=11,
)
for key in series:
previous = None
style = Qt.PenStyle.DashLine if key == "diastolic_pressure" else Qt.PenStyle.SolidLine
for item in self.data.items:
if item.series != key:
continue
if item.value is None:
previous = None
continue
point = self._position(item)
if previous is not None:
painter.setPen(QPen(QColor(_NEUTRAL), 2, style))
painter.drawLine(previous, point)
previous = point
for item in self.data.items:
if item.series == key and item.value is not None:
painter.setPen(QPen(QColor(item.color), 2))
painter.setBrush(QColor(item.color))
_draw_marker(painter, self._position(item), key)
self._text(
painter,
QRectF(plot.left(), plot.bottom() + 8, 104, 20),
origin.date().isoformat(),
size=11,
)
if span:
last = max(item.recorded_at for item in self.data.items if item.value is not None)
self._text(
painter,
QRectF(plot.right() - 91, plot.bottom() + 8, 100, 20),
last.date().isoformat(),
size=11,
)
def _paint_snapshot(
self, painter: QPainter, item: VisualDatum, top: int, width: float
) -> None:
self._text(painter, QRectF(0, top, width, 21), item.label, color="#1A1C1F", size=12)
self._text(
painter, QRectF(0, top + 22, width, 24), item.value_text, color=item.color, size=17
)
note = f"记录状态:{_status_label(item)}" if item.status else "记录未提供状态"
if item.interval:
note = f"原始记录区间 · {note}"
if item.reference:
low, high = item.reference
values = [low, high, *(item.interval or (item.value,))]
scale = max(value for value in values if value is not None)
lower, upper = (
min(value / scale for value in values if value is not None),
max(value / scale for value in values if value is not None),
)
span = upper - lower
def x(value: float) -> float:
return 8 + (width - 16) * ((value / scale - lower) / span if span else 0.5)
painter.setPen(QPen(QColor("#E4E7EC"), 5))
painter.drawLine(QPointF(8, top + 54), QPointF(width - 8, top + 54))
painter.setPen(QPen(QColor(_NEUTRAL), 5))
painter.drawLine(QPointF(x(low), top + 54), QPointF(x(high), top + 54))
painter.setPen(QPen(QColor(item.color), 2))
painter.setBrush(QColor(item.color))
if item.interval:
painter.drawLine(
QPointF(x(item.interval[0]), top + 54), QPointF(x(item.interval[1]), top + 54)
)
else:
painter.drawEllipse(QPointF(x(item.value), top + 54), 4, 4)
note = f"{note} · 参考 {low:g}–{high:g} {item.unit}"
self._text(painter, QRectF(0, top + 65, width, 17), note, color=item.color, size=10)
else:
self._text(painter, QRectF(0, top + 53, width, 22), note, color=item.color, size=11)
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
if self._trend:
self._paint_trend(painter)
else:
for index, item in enumerate(self.data.items):
top = index * self._row_height
if self.data.kind == "metrics":
width = (self.width() - 24 * (self._columns - 1)) / self._columns
painter.save()
painter.translate((index % self._columns) * (width + 24), 0)
self._paint_snapshot(painter, item, (index // self._columns) * self._row_height, width)
painter.restore()
elif self.data.kind == "risks":
self._text(
painter, QRectF(0, top, self.width(), 23), item.label, color="#1A1C1F"
)
width = (self.width() - 9) / 4
for offset, (level, label) in enumerate(_RISK_LABELS.items()):
rect = QRectF(offset * (width + 3), top + 29, width, 28)
active = level == item.status
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(item.color if active else "#F1F3F6"))
painter.drawRoundedRect(rect, 4, 4)
self._text(
painter,
rect.adjusted(6, 0, -3, 0),
label,
color="#FFFFFF" if active else _NEUTRAL,
size=11,
)
else:
painter.setPen(QPen(QColor("#E0E4EF"), 1))
painter.drawLine(QPointF(6, top + 10), QPointF(6, top + 78))
painter.setBrush(QColor(_NEUTRAL))
painter.drawEllipse(QPointF(6, top + 12), 4, 4)
for line, text in enumerate((item.time_label, item.label, item.raw_value)):
self._text(
painter,
QRectF(21, top + line * 22, self.width() - 21, 22),
text,
color="#1A1C1F" if line == 1 else _NEUTRAL,
)
painter.end()
class ReportVisualCard(QFrame):
def __init__(self, data: ReportVisual, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.data = data
self.setObjectName("AiConsultReportVisual")
self.setProperty("visualKind", data.kind)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
self.setMinimumWidth(0)
self.setStyleSheet("""
QFrame#AiConsultReportVisual {background:#FFFFFF; border:1px solid #EDEDEE; border-radius:10px;}
QFrame#AiConsultReportVisual QLabel {border:0; background:transparent; color:#697386; font-size:12px;}
QFrame#AiConsultReportVisual QLabel#AiConsultReportVisualTitle {color:#1A1C1F; font-size:15px; font-weight:600;}
QFrame#AiConsultReportVisual QPushButton {color:#4F63D9; background:#F7F8FC; border:1px solid #E4E7F4; border-radius:6px; padding:5px 9px; font-size:12px;}
QFrame#AiConsultReportVisual QTableWidget {background:#FFFFFF; color:#1A1C1F; border:1px solid #EDEDEE; font-size:12px;}
QFrame#AiConsultReportVisual QHeaderView::section {background:#F7F8FC; color:#697386; border:0; padding:5px;}
""")
root = QVBoxLayout(self)
root.setContentsMargins(14, 12, 14, 12)
root.setSpacing(8)
title = QLabel(data.title, self)
title.setObjectName("AiConsultReportVisualTitle")
title.setWordWrap(True)
root.addWidget(title)
self.chart = ReportPlot(data, self)
root.addWidget(self.chart)
self.toggle_button = QPushButton("查看原始记录", self)
self.toggle_button.setCheckable(True)
root.addWidget(self.toggle_button, alignment=Qt.AlignmentFlag.AlignLeft)
self.table = QTableWidget(len(data.items), 5, self)
self.table.setObjectName("AiConsultReportVisualTable")
self.table.setHorizontalHeaderLabels(
["记录日期 / 时间", "指标 / 方名", "记录值", "状态", "记录参考区间"]
)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setWordWrap(True)
self.table.setMinimumWidth(0)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.verticalHeader().hide()
self.table.setMaximumHeight(230)
for index, item in enumerate(data.items):
reference = (
f"{item.reference[0]:g}–{item.reference[1]:g} {item.unit}"
if item.reference
else "—"
)
values = (
item.time_label or "未提供日期",
item.label,
item.value_text + ("(原始记录区间)" if item.interval else ""),
_status_label(item) or "未提供",
reference,
)
for column, value in enumerate(values):
cell = QTableWidgetItem(value)
cell.setToolTip(ReportPlot._readout(item))
if value in {"—", "未提供", "未提供日期", "未记录", "未标注"}:
cell.setForeground(QColor(_NEUTRAL))
elif column in {2, 3}:
cell.setForeground(QColor(item.color))
self.table.setItem(index, column, cell)
self.table.hide()
root.addWidget(self.table)
self.toggle_button.toggled.connect(self._toggle_table)
source = QLabel(data.source, self)
source.setObjectName("AiConsultReportVisualSource")
source.setTextFormat(Qt.TextFormat.PlainText)
source.setWordWrap(True)
root.addWidget(source)
def _toggle_table(self, visible: bool) -> None:
self.table.setVisible(visible)
self.toggle_button.setText("收起原始记录" if visible else "查看原始记录")
def create_report_visual(
data: GlucoseChartData | ReportVisual,
parent: QWidget | None = None,
) -> QWidget:
return (
GlucoseReportCard(data, parent)
if isinstance(data, GlucoseChartData)
else ReportVisualCard(data, parent)
)
@@ -717,19 +717,21 @@ class OrderDetailDrawer(QDialog):
QTimer.singleShot(0, self.sync_to_owner)
def sync_to_owner(self) -> None:
owner = self._owner
if owner is not None:
# The scrim is an overlay of its owner, including when that owner is
# a standalone diagnosis window rather than the workstation itself.
origin = owner.mapToGlobal(QPoint(0, 0))
self.setGeometry(QRect(origin, owner.size()))
self.drawer_panel.setFixedWidth(max(1, round(owner.width() * self.drawer_ratio)))
return
target = main_window_target_rect(self)
if target is not None:
self.setGeometry(target)
self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio)))
return
owner = self._owner
if owner is None:
self.resize(1152, 720)
width = round(self.width() * self.drawer_ratio)
else:
origin = owner.mapToGlobal(QPoint(0, 0))
self.setGeometry(origin.x(), origin.y(), owner.width(), owner.height())
width = round(owner.width() * self.drawer_ratio)
self.resize(1152, 720)
width = round(self.width() * self.drawer_ratio)
self.drawer_panel.setFixedWidth(max(1, width))
def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - Qt API
@@ -1842,11 +1844,17 @@ class DiagnosisDialog(QDialog):
def _sync_host_geometry(self) -> None:
if self._embedded:
return
owner = self._owner
if not self._standalone_readonly and owner is not None and owner is not self:
# Ordinary windows use the centered 85% rectangle. A drawer's
# translucent scrim must cover the complete live owner client area.
origin = owner.mapToGlobal(QPoint(0, 0))
self.setGeometry(QRect(origin, owner.size()))
return
target = main_window_target_rect(self)
if target is not None:
self.setGeometry(target)
return
owner = self._owner
if owner is None:
if self.width() < 760 or self.height() < 520:
self.resize(1024, 640)
@@ -1935,6 +1943,7 @@ class DiagnosisDialog(QDialog):
QEvent.Type.Resize,
QEvent.Type.Move,
QEvent.Type.Show,
QEvent.Type.WindowStateChange,
}:
self._sync_host_geometry()
self._update_drawer_geometry()
+5
View File
@@ -228,6 +228,11 @@ def _message(p: QPainter, w: float) -> None:
# --- Shell chrome ---------------------------------------------------------
@_glyph("more", "ellipsis")
def _more(p: QPainter, w: float) -> None:
for x in (5.0, 12.0, 19.0):
_dot(p, x, 12.0, 1.5)
@_glyph("fold", "panel_close")
def _fold(p: QPainter, w: float) -> None:
@@ -348,16 +348,16 @@ class PrescriptionLibraryPage(QWidget):
table_layout.setSpacing(0)
self.table = LibraryTable(
[
TableColumn("id", "ID", 62),
TableColumn("prescription_name", "处方名称", 162),
TableColumn("formula_type", "处方类型", 88, _formula_text),
TableColumn("herbs", "药材数量", 84, _herb_count),
TableColumn("herbs", "药材组成(部分)", 296, _herbs_detail),
TableColumn("efficacy", "功效主治", 170, _efficacy_text),
TableColumn("is_public", "公开范围", 135, _visibility_text),
TableColumn("creator_name", "创建人", 100),
TableColumn("create_time", "创建时间", 178, _create_time_cell),
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
TableColumn("id", "ID", 72),
TableColumn("prescription_name", "处方名称", 170),
TableColumn("formula_type", "处方类型", 76, _formula_text),
TableColumn("herbs", "药材数量", 76, _herb_count),
TableColumn("herbs", "药材组成(部分)", 260, _herbs_detail),
TableColumn("efficacy", "功效主治", 90, _efficacy_text),
TableColumn("is_public", "公开范围", 134, _visibility_text),
TableColumn("creator_name", "创建人", 136),
TableColumn("create_time", "创建时间", 122, _create_time_cell),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
]
)
self.table.setObjectName("PrescriptionLibraryTable")
@@ -371,8 +371,6 @@ class PrescriptionLibraryPage(QWidget):
self.table.horizontalHeader().setMinimumSectionSize(36)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
for column, width in enumerate((54, 170, 76, 76, 260, 90, 134, 136, 122, 150)):
self.table.setColumnWidth(column, width)
for column in (0, 2, 3, 5, 9):
self.table.horizontalHeaderItem(column).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setWordWrap(False)
@@ -1694,8 +1694,8 @@ class PrescriptionsPage(QWidget):
def _set_ai_columns(self, enabled: bool) -> None:
changed = enabled != self._ai_enabled
self._ai_enabled = enabled
self.table.setColumnHidden(11, not enabled)
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
for column in (11, 12):
self.table.setColumnHidden(column, not (enabled and self._agreement_visible))
if enabled:
header = self.table.horizontalHeader()
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
@@ -1708,7 +1708,8 @@ class PrescriptionsPage(QWidget):
def _set_agreement_visible(self, visible: bool) -> None:
self._agreement_visible = visible
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
for column in (11, 12):
self.table.setColumnHidden(column, not (self._ai_enabled and visible))
self.ai_statistics_button.setVisible(
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
+219 -78
View File
@@ -89,6 +89,7 @@ from ..dialogs.prescription_ai import (
)
from ..filter_disclosure import FilterDisclosure
from ..reception_style import body_family, heading_family, number_family
from ..reception_workspace import RECEPTION_WORKSPACE_QSS, ReceptionWorkbench
from ..report_provenance import report_provenance_lines
from ..theme import mark_business_dialog
from ..widgets import (
@@ -1182,6 +1183,7 @@ QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
border-radius: 5px;
}
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled { background-color: #FFFFFF; }
QWidget#ReceptionPage QPushButton#ReceptionMoreButton { padding: 0 6px; }
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
border-top-left-radius: 0;
border-top-right-radius: 0;
@@ -1924,6 +1926,30 @@ def _queue_glucose_value(record: Any) -> object:
)
def _is_numeric_reading(value: object) -> bool:
"""Separate a scalar measurement from a patient's free-text description."""
return bool(re.fullmatch(
r"(?:空腹(?:血糖)?[::]?\s*)?[-+]?\d+(?:\.\d+)?\s*(?:mmol/l|mg/l|mg/g|%)?",
str("" if value is None else value).strip(), re.IGNORECASE,
))
def _reading_text(value: object, unit: str) -> str:
text = display_text(value)
if _is_numeric_reading(value) and unit.lower() not in text.lower():
return f"{text} {unit}"
return text
def _history_fact(text: str, *captions: str) -> str | None:
"""Lift an explicitly labelled fact into the overview; retain its source text."""
for line in text.splitlines():
caption, separator, value = re.sub(r"[::]", ":", line).partition(":")
if separator and caption.strip() in captions and value.strip():
return value.strip()
return None
def _glucose_tone(value: object) -> str:
number = _as_float(value)
if number is None:
@@ -2474,6 +2500,13 @@ class _ReceptionHero(QFrame):
if layout.direction() != direction:
layout.setDirection(direction)
self.updateGeometry()
action_group = getattr(self, "action_group", None)
if action_group is not None:
action_group.setDirection(
QBoxLayout.Direction.LeftToRight
if direction == QBoxLayout.Direction.TopToBottom and self.width() >= 620
else QBoxLayout.Direction.TopToBottom
)
actions = getattr(self, "actions_layout", None)
if actions is not None:
compact = self.width() < 520
@@ -2488,21 +2521,7 @@ class _ReceptionHero(QFrame):
class _ClinicalInfoPanel(QWidget):
"""Stack history and measurements before their text gets squeezed."""
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
super().resizeEvent(event)
layout = self.layout()
if not isinstance(layout, QBoxLayout):
return
stacked = self.width() < 760
direction = (
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight
)
if layout.direction() != direction:
layout.setDirection(direction)
layout.setStretch(0, 0 if stacked else 42)
layout.setStretch(1, 0 if stacked else 58)
"""Let the clinical history use the full reading column."""
class _ClinicalPageStack(QStackedWidget):
@@ -3760,13 +3779,13 @@ class QueueRow(QWidget):
super().__init__(parent)
self.setObjectName("ReceptionQueueRow")
self._selected = False
self.setFixedHeight(104)
self.setFixedHeight(82)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 17, 16, 17)
layout.setSpacing(10)
layout.setContentsMargins(14, 12, 12, 12)
layout.setSpacing(8)
top = QHBoxLayout()
top.setSpacing(14)
top.setSpacing(8)
name = QLabel(
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
)
@@ -3814,23 +3833,27 @@ class QueueRow(QWidget):
layout.addLayout(top)
bottom = QHBoxLayout()
bottom.setSpacing(8)
subline = QLabel(_queue_condition_summary(record))
summary = _queue_condition_summary(record)
subline = QLabel("" if summary == "暂无病情摘要" else summary)
subline.setToolTip(summary if summary != "暂无病情摘要" else "")
subline.setObjectName("ReceptionQueueSubline")
subline.setMinimumWidth(0)
subline.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
bottom.addWidget(subline, 1)
metric = _queue_glucose_value(record)
drop = _GlucoseDropIcon(_glucose_tone(metric), self)
drop.setVisible(metric not in (None, ""))
drop.setVisible(metric not in (None, "") and _is_numeric_reading(metric))
self.glucose_drop = drop
bottom.addWidget(drop, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
metric_label = QLabel(
f"{display_text(metric)} mmol/L" if metric not in (None, "") else "",
(_reading_text(metric, "mmol/L") if _is_numeric_reading(metric) else "血糖有描述")
if metric not in (None, "") else "",
self,
)
metric_label.setObjectName("ReceptionQueueMetric")
tone = _glucose_tone(metric)
tone = _glucose_tone(metric) if _is_numeric_reading(metric) else "description"
metric_label.setProperty("tone", tone)
metric_label.setToolTip(display_text(metric))
metric_label.setVisible(metric not in (None, ""))
bottom.addWidget(metric_label, 0, Qt.AlignmentFlag.AlignRight)
layout.addLayout(bottom)
@@ -4080,7 +4103,7 @@ class ReceptionPage(QWidget):
super().__init__(parent)
self.setObjectName("ReceptionPage")
self.setStyleSheet(
RECEPTION_QSS.replace("$body_family", body_family())
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
.replace("$heading_family", heading_family())
.replace("$number_family", number_family())
)
@@ -4180,7 +4203,7 @@ class ReceptionPage(QWidget):
)
root = QVBoxLayout(self)
root.setContentsMargins(11, 16, 11, 20)
root.setContentsMargins(12, 16, 12, 12)
root.setSpacing(0)
splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -4191,8 +4214,8 @@ class ReceptionPage(QWidget):
splitter.addWidget(detail_panel)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setHandleWidth(10)
splitter.setSizes([399, 1072])
splitter.setHandleWidth(12)
splitter.setSizes([286, 1185])
root.addWidget(splitter, 1)
self.poll_timer = QTimer(self)
@@ -4209,7 +4232,7 @@ class ReceptionPage(QWidget):
super().resizeEvent(event)
panel = getattr(self, "queue_panel", None)
if panel is not None:
width = 340 if self.width() < 1000 else 399
width = 272 if self.width() < 1100 else 286
if panel.minimumWidth() != width:
panel.setFixedWidth(width)
@@ -4225,9 +4248,9 @@ class ReceptionPage(QWidget):
panel = QFrame()
panel.setObjectName("ReceptionQueuePanel")
self.queue_panel = panel
panel.setFixedWidth(399)
panel.setFixedWidth(286)
layout = QVBoxLayout(panel)
layout.setContentsMargins(14, 14, 12, 14)
layout.setContentsMargins(10, 14, 10, 10)
layout.setSpacing(10)
header = QWidget(panel)
@@ -4279,7 +4302,7 @@ class ReceptionPage(QWidget):
search_row.addWidget(self.search_edit, 1)
filter_layout.addLayout(search_row)
filter_row = QHBoxLayout()
filter_row = QGridLayout()
filter_row.setSpacing(6)
for status, label in ((None, "全部"), (1, "待接诊"), (2, "问诊中"), (3, "已完成")):
button = QPushButton(label, self.queue_filter_panel)
@@ -4290,7 +4313,8 @@ class ReceptionPage(QWidget):
lambda _checked=False, value=status: self._set_queue_filter(value)
)
self._queue_filter_buttons[status] = button
filter_row.addWidget(button, 1)
position = len(self._queue_filter_buttons) - 1
filter_row.addWidget(button, position // 2, position % 2)
self._queue_filter_buttons[1].setChecked(True)
filter_layout.addLayout(filter_row)
layout.addWidget(self.queue_filter_panel)
@@ -4334,7 +4358,7 @@ class ReceptionPage(QWidget):
def _build_detail_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("ReceptionDetailPanel")
panel.setMinimumWidth(430)
panel.setMinimumWidth(390)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(0)
@@ -4350,7 +4374,7 @@ class ReceptionPage(QWidget):
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
content = QWidget(scroll)
content.setMinimumWidth(390)
content.setMinimumWidth(0)
content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
detail_layout = QVBoxLayout(content)
detail_layout.setContentsMargins(0, 0, 0, 0)
@@ -4358,9 +4382,9 @@ class ReceptionPage(QWidget):
hero = _ReceptionHero(content)
hero.setObjectName("ReceptionHero")
hero.setMinimumHeight(112)
hero.setMinimumHeight(96)
hero_layout = QVBoxLayout(hero)
hero_layout.setContentsMargins(16, 22, 16, 22)
hero_layout.setContentsMargins(18, 16, 18, 16)
hero_layout.setSpacing(4)
self.detail_banner = MessageBanner(parent=hero)
hero_layout.addWidget(self.detail_banner)
@@ -4373,7 +4397,7 @@ class ReceptionPage(QWidget):
self.patient_avatar_label = QLabel("患", hero)
self.patient_avatar_label.setObjectName("ReceptionAvatar")
self.patient_avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.patient_avatar_label.setFixedSize(64, 64)
self.patient_avatar_label.setFixedSize(48, 48)
identity_head.addWidget(self.patient_avatar_label)
identity = QVBoxLayout()
identity.setSpacing(8)
@@ -4400,6 +4424,7 @@ class ReceptionPage(QWidget):
actions.setSpacing(14)
actions.addStretch(1)
action_group = QVBoxLayout()
hero.action_group = action_group
action_group.setContentsMargins(0, 0, 0, 0)
action_group.setSpacing(6)
self.current_assistant_label = QLabel("当前医助:—", hero)
@@ -4428,7 +4453,9 @@ class ReceptionPage(QWidget):
self.video_button.setProperty("variant", "primary")
self.video_button.clicked.connect(self._request_video)
actions.addWidget(self.video_button)
self.more_button = QPushButton("···", hero)
self.more_button = QPushButton(hero)
self.more_button.setIcon(icons.icon("more", "#5D6B80", 16))
self.more_button.setIconSize(QSize(16, 16))
self.more_button.setToolTip("更多操作")
self.more_button.setAccessibleName("更多操作")
self.more_button.setObjectName("ReceptionMoreButton")
@@ -4472,7 +4499,7 @@ class ReceptionPage(QWidget):
clinical_card = QFrame(content)
clinical_card.setObjectName("ReceptionClinicalCard")
clinical_card.setMinimumHeight(526)
clinical_card.setMinimumHeight(0)
clinical_card.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Minimum,
@@ -4510,15 +4537,18 @@ class ReceptionPage(QWidget):
QSizePolicy.Policy.Minimum,
)
consultation_layout = QHBoxLayout(consultation_page)
consultation_layout.setContentsMargins(12, 22, 12, 13)
consultation_layout.setContentsMargins(18, 18, 18, 18)
consultation_layout.setSpacing(10)
self.clinical_info_group = consultation_page
history_card = QFrame(consultation_page)
history_card.setObjectName("ReceptionHistoryCard")
history_layout = QVBoxLayout(history_card)
history_layout.setContentsMargins(8, 4, 18, 0)
history_layout.setSpacing(15)
history_layout.setContentsMargins(0, 0, 0, 0)
history_layout.setSpacing(14)
history_title = QLabel("病情与用药", history_card)
history_title.setObjectName("ReceptionCaseHeading")
history_layout.addWidget(history_title)
self.case_labels: dict[str, QLabel] = {}
def add_case_field(
@@ -4528,7 +4558,8 @@ class ReceptionPage(QWidget):
*,
stacked: bool = False,
) -> None:
field = QWidget(history_card)
field = QFrame(history_card)
field.setObjectName("ReceptionCaseField")
if stacked:
field_layout = QVBoxLayout(field)
field_layout.setContentsMargins(0, 0, 0, 0)
@@ -4555,8 +4586,10 @@ class ReceptionPage(QWidget):
self.case_labels[key] = value_label
target.addWidget(field)
add_case_field(history_layout, "chief", "主诉", stacked=True)
add_case_field(history_layout, "present", "现病史", stacked=True)
add_case_field(history_layout, "chief", "本次主诉", stacked=True)
add_case_field(history_layout, "medication", "当前用药", stacked=True)
add_case_field(history_layout, "course", "患病时长")
add_case_field(history_layout, "present", "现病史原文", stacked=True)
add_case_field(history_layout, "past", "既往史")
add_case_field(history_layout, "family", "家族史")
add_case_field(history_layout, "allergy", "过敏史")
@@ -4594,20 +4627,35 @@ class ReceptionPage(QWidget):
add_case_field(history_layout, "diet", "饮食习惯")
history_layout.addStretch(1)
consultation_layout.addWidget(history_card, 42)
consultation_layout.addWidget(history_card, 1)
right_clinical = QWidget(consultation_page)
right_layout = QVBoxLayout(right_clinical)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(10)
self.patient_summary_card, self.patient_summary_labels = self._field_card(
"患者基本信息",
(("性别 / 年龄", "gender_age"), ("联系方式", "phone"),
("患病时长", "course"), ("已记录诊断", "diagnosis")),
)
self.patient_summary_card.setObjectName("ReceptionPatientSummaryCard")
self.patient_summary_card.setAccessibleName("患者基本信息")
for label in self.patient_summary_labels.values():
label.setObjectName("ReceptionPatientSummaryValue")
label.setStyleSheet("")
label.setTextFormat(Qt.TextFormat.PlainText)
label.setMinimumWidth(0)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
right_layout.addWidget(self.patient_summary_card)
vitals_card = QFrame(right_clinical)
vitals_card.setObjectName("ReceptionVitalsCard")
vitals_card.setMinimumHeight(152)
vitals_card.setMinimumHeight(0)
vitals_card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
vitals_layout = QVBoxLayout(vitals_card)
vitals_layout.setContentsMargins(12, 4, 2, 18)
vitals_layout.setSpacing(12)
vitals_layout.setContentsMargins(14, 14, 14, 14)
vitals_layout.setSpacing(8)
vitals_title = QLabel("生命体征", vitals_card)
vitals_title.setProperty("receptionBlockTitle", True)
vitals_layout.addWidget(vitals_title)
@@ -4661,7 +4709,9 @@ class ReceptionPage(QWidget):
cell_layout.addStretch(1)
self.vital_labels[key] = value_label
self.vital_unit_labels[key] = unit_label
vitals_grid.addWidget(cell, 0, column)
row, position, span = ((0, 0, 1), (0, 1, 1), (0, 2, 1), (1, 0, 2), (1, 2, 1))[column]
vitals_grid.addWidget(cell, row, position, 1, span)
for column in range(3):
vitals_grid.setColumnStretch(column, 1)
vitals_layout.addLayout(vitals_grid)
right_layout.addWidget(vitals_card)
@@ -4669,12 +4719,15 @@ class ReceptionPage(QWidget):
labs_card = QFrame(right_clinical)
labs_card.setObjectName("ReceptionLabsCard")
labs_layout = QVBoxLayout(labs_card)
labs_layout.setContentsMargins(12, 3, 2, 0)
labs_layout.setContentsMargins(14, 14, 14, 14)
labs_layout.setSpacing(9)
labs_title = QLabel("检查结果", labs_card)
labs_title.setProperty("receptionBlockTitle", True)
labs_layout.addWidget(labs_title)
labs_grid = QGridLayout()
self.lab_grid = labs_grid
self.lab_cells: dict[str, QFrame] = {}
self._show_missing_labs = False
labs_grid.setContentsMargins(0, 0, 0, 0)
labs_grid.setHorizontalSpacing(7)
labs_grid.setVerticalSpacing(7)
@@ -4694,7 +4747,7 @@ class ReceptionPage(QWidget):
cell = QFrame(labs_card)
cell.setObjectName("ReceptionLabCell")
cell_layout = QVBoxLayout(cell)
cell_layout.setContentsMargins(10, 10, 8, 6)
cell_layout.setContentsMargins(0, 6, 0, 8)
cell_layout.setSpacing(6)
caption_label = QLabel(caption, cell)
caption_label.setProperty("receptionMetricCaption", True)
@@ -4711,13 +4764,20 @@ class ReceptionPage(QWidget):
cell_layout.addStretch(1)
self.lab_labels[key] = value_label
self.lab_state_labels[key] = state_label
row, column = divmod(index, 4)
self.lab_cells[key] = cell
row, column = divmod(index, 2)
labs_grid.addWidget(cell, row, column)
labs_grid.setRowStretch(row, 1)
labs_grid.setColumnStretch(column, 1)
labs_layout.addLayout(labs_grid, 1)
right_layout.addWidget(labs_card, 1)
consultation_layout.addWidget(right_clinical, 58)
labs_layout.addLayout(labs_grid)
self.labs_empty_label = QLabel("暂未录入检查结果", labs_card)
self.labs_empty_label.setProperty("role", "muted")
labs_layout.addWidget(self.labs_empty_label)
self.lab_missing_button = QPushButton("", labs_card)
self.lab_missing_button.setObjectName("ReceptionMissingLabsButton")
self.lab_missing_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.lab_missing_button.clicked.connect(self._toggle_missing_labs)
labs_layout.addWidget(self.lab_missing_button)
right_layout.addWidget(labs_card)
self.clinical_pages.addWidget(consultation_page)
appointment_card, self.appointment_labels = self._field_card(
@@ -5021,7 +5081,6 @@ class ReceptionPage(QWidget):
health_layout.addWidget(self.health_summary_label)
self.health_scroll = add_scrolling_page(health_card)
detail_layout.addWidget(clinical_card)
detail_layout.addSpacing(10)
ai_panels = _ReceptionAiPanels(content)
ai_panels.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
@@ -5301,7 +5360,7 @@ class ReceptionPage(QWidget):
assistant_header.addStretch(1)
assistant_layout.addLayout(assistant_header)
assistant_hint = QLabel(
"您好,我是 AI 问诊助手,有什么可以帮助您了解患者情况的吗?",
"结合当前患者资料,快速核对问诊要点。",
assistant_card,
)
assistant_hint.setObjectName("ReceptionAiAssistantHint")
@@ -5315,8 +5374,10 @@ class ReceptionPage(QWidget):
self.ai_prompt_buttons: list[QPushButton] = []
def prompt_button(label: str) -> QPushButton:
def prompt_button(label: str, question: str) -> QPushButton:
button = QPushButton(label, assistant_card)
button.setToolTip(question)
button.setAccessibleName(question)
button.setProperty("receptionAiPrompt", True)
button.setMinimumWidth(0)
button.setSizePolicy(
@@ -5324,20 +5385,20 @@ class ReceptionPage(QWidget):
QSizePolicy.Policy.Fixed,
)
button.clicked.connect(
lambda _checked=False, prompt=label: self._open_ai_report_for_prompt(prompt)
lambda _checked=False, prompt=question: self._open_ai_report_for_prompt(prompt)
)
self.ai_prompt_buttons.append(button)
return button
prompt_host = _AiFlowHost(assistant_card, horizontal_spacing=8, vertical_spacing=8)
prompt_host = _AiFlowHost(assistant_card, horizontal_spacing=6, vertical_spacing=6)
prompt_host.setObjectName("ReceptionAiPrompts")
for label in (
"基于当前病史,下一步检查建议?",
"该患者的用药调整建议?",
"糖尿病教育要点有哪些?",
"并发症筛查建议?",
for label, question in (
("检查建议", "基于当前病史,下一步检查建议?"),
("用药核对", "该患者的用药调整建议?"),
("健康教育", "糖尿病教育要点有哪些?"),
("并发症筛查", "并发症筛查建议?"),
):
prompt_host.flow.addWidget(prompt_button(label))
prompt_host.flow.addWidget(prompt_button(label, question))
assistant_layout.addWidget(prompt_host)
assistant_layout.addStretch(1)
@@ -5355,17 +5416,65 @@ class ReceptionPage(QWidget):
assistant_input_row.addWidget(self.ai_send_button)
assistant_input_host.hide()
ai_row.addWidget(assistant_card, 23)
detail_layout.addWidget(ai_panels)
insights = QWidget()
insights_layout = QVBoxLayout(insights)
insights_layout.setContentsMargins(0, 0, 0, 0)
insights_layout.setSpacing(12)
insights_layout.addWidget(right_clinical)
insights_layout.addWidget(ai_panels)
insights_layout.addStretch(1)
self.detail_tabs.currentChanged.connect(self._detail_tab_changed)
self._detail_tab_changed(0)
scroll.setWidget(content)
self.detail_stack.addWidget(scroll)
detail_layout.removeWidget(hero)
detail_page = QWidget()
workspace_layout = QVBoxLayout(detail_page)
workspace_layout.setContentsMargins(0, 0, 0, 0)
workspace_layout.setSpacing(12)
workspace_layout.addWidget(hero)
self.workbench = ReceptionWorkbench(scroll, insights, detail_page)
self.insights_scroll = self.workbench.insights_scroll
workspace_layout.addWidget(self.workbench, 1)
self.detail_stack.addWidget(detail_page)
panel_layout.addWidget(self.detail_stack)
self._reset_detail_content()
return panel
def _toggle_missing_labs(self) -> None:
self._show_missing_labs = not self._show_missing_labs
self._sync_lab_rows()
def _sync_lab_rows(self) -> None:
recorded = 0
row = column = 0
for key, cell in self.lab_cells.items():
self.lab_grid.removeWidget(cell)
text = self.lab_labels[key].text().strip()
has_value = text not in {"", "—", "正在加载…"}
recorded += int(has_value)
visible = has_value or self._show_missing_labs
cell.setVisible(visible)
if not visible:
continue
wide = bool(cell.property("description")) and has_value
if wide and column:
row += 1
column = 0
self.lab_grid.addWidget(cell, row, column, 1, 2 if wide else 1)
if wide or column:
row += 1
column = 0
else:
column = 1
missing = len(self.lab_cells) - recorded
self.labs_empty_label.setVisible(recorded == 0 and not self._show_missing_labs)
self.lab_missing_button.setVisible(missing > 0)
self.lab_missing_button.setText(
"收起未记录项" if self._show_missing_labs else f"显示 {missing} 项未记录"
)
def _detail_tab_changed(self, index: int) -> None:
if not 0 <= index < self.detail_tabs.count():
return
@@ -7043,7 +7152,7 @@ class ReceptionPage(QWidget):
def _add_queue_row(self, record: Any) -> None:
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, record)
item.setSizeHint(QSize(0, 120))
item.setSizeHint(QSize(0, 88))
self.queue_list.addItem(item)
self.queue_list.setItemWidget(item, QueueRow(record))
@@ -7348,6 +7457,7 @@ class ReceptionPage(QWidget):
self._pending_tongue_images.clear()
self._pending_report_files.clear()
self._render_pending_attachments()
self.insights_scroll.verticalScrollBar().setValue(0)
self._selected_record = record
self._selected_appointment_id = appointment_id
self._selected_detail = None
@@ -8009,6 +8119,12 @@ class ReceptionPage(QWidget):
)
self.patient_labels["customer"].setText(display_text(customer))
self.patient_labels["prescription"].setText("已开具" if has_prescription else "暂未开具")
demographics = " · ".join(
part for part in (gender if gender != "—" else "",
f"{age}岁" if age not in (None, "") else "") if part
)
self._set_patient_summary_value("gender_age", demographics)
self._set_patient_summary_value("phone", self.patient_labels["phone"].text())
systolic = first_value(
diagnosis,
@@ -8099,6 +8215,15 @@ class ReceptionPage(QWidget):
"0",
}
def _set_patient_summary_value(self, key: str, value: Any) -> None:
label = self.patient_summary_labels[key]
text = self._case_display_text(value)
text = "未记录" if text == "—" else text
label.setText(text)
label.setProperty("missing", text in {"未记录", "正在加载…"})
label.style().unpolish(label)
label.style().polish(label)
def _set_case_metric(self, key: str, value: Any, *, alert: bool = False) -> None:
label = self.case_metric_labels[key]
caption = {
@@ -8326,8 +8451,7 @@ class ReceptionPage(QWidget):
blood_sugar_text = self._case_display_text(blood_sugar)
if blood_sugar_text.startswith("空腹"):
blood_sugar_text = blood_sugar_text[2:].lstrip(":: ")
if "mmol/l" not in blood_sugar_text.lower():
blood_sugar_text = f"{blood_sugar_text} mmol/L"
blood_sugar_text = _reading_text(blood_sugar_text, "mmol/L")
case_items["空腹血糖"] = blood_sugar_text
lines.append(f"空腹血糖:{blood_sugar_text}")
@@ -8386,6 +8510,10 @@ class ReceptionPage(QWidget):
)
case_values = {
"chief": chief,
"medication": case_value("current_medications", "current_medicine", "current_medication")
or _history_fact(str(present or ""), "在用药物", "当前用药", "用药情况"),
"course": case_value("disease_course_text", "disease_course", "course_text", "diabetes_discovery_year_text")
or _history_fact(str(present or ""), "患病时长", "病程", "患病时间"),
"present": present,
"past": past,
"family": family,
@@ -8395,6 +8523,10 @@ class ReceptionPage(QWidget):
}
for key, value in case_values.items():
self.case_labels[key].setText(display_text(value))
self._set_patient_summary_value("course", case_values["course"])
self._set_patient_summary_value(
"diagnosis", case_value("clinical_diagnosis", "diagnosis_name", "disease_name")
)
lab_definitions: dict[str, tuple[tuple[str, ...], str, tuple[str, ...]]] = {
"fasting": (
@@ -8433,17 +8565,17 @@ class ReceptionPage(QWidget):
health_lines: list[str] = []
for key, (value_keys, unit, status_keys) in lab_definitions.items():
value = case_value(*value_keys)
text = display_text(value)
if value not in (None, "") and unit.lower() not in str(value).lower():
text = f"{text} {unit}"
numeric = _is_numeric_reading(value)
text = _reading_text(value, unit)
self.lab_labels[key].setText(text)
self.lab_cells[key].setProperty("description", value not in (None, "") and not numeric)
status_value = case_value(*status_keys)
computed = _lab_status_for_value(key, value)
computed = _lab_status_for_value(key, value) if numeric else None
status_label = self.lab_state_labels[key]
status_label.setProperty(
"warning", key in {"fasting", "postprandial"} and _glucose_tone(value) == "warn"
)
if status_value not in (None, ""):
if status_value not in (None, "") and numeric:
status_text = display_text(status_value)
if status_text.strip().lower() in {"正常", "normal", "0", "阴性"}:
status_text = "正常"
@@ -8478,6 +8610,7 @@ class ReceptionPage(QWidget):
status_label.setVisible(False)
if value not in (None, ""):
health_lines.append(f"{self.lab_labels[key].parentWidget().findChild(QLabel).text()}:{text}")
self._sync_lab_rows()
clinical_diagnosis = case_value("clinical_diagnosis", "diagnosis")
health_header = (
@@ -8847,6 +8980,8 @@ class ReceptionPage(QWidget):
label.setText("—")
for label in getattr(self, "patient_labels", {}).values():
label.setText("—")
for key in self.patient_summary_labels:
self._set_patient_summary_value(key, "正在加载…" if seed else None)
if hasattr(self, "diagnosis_text"):
self._set_case_placeholder(
"正在加载病例…" if seed else "尚未填写病例信息"
@@ -8897,6 +9032,8 @@ class ReceptionPage(QWidget):
for key, label in self.lab_labels.items():
label.setText("—")
self.lab_state_labels[key].setVisible(False)
self._show_missing_labs = False
self._sync_lab_rows()
self._render_notes([])
self._update_action_state(seed or {}, {})
@@ -8909,6 +9046,8 @@ class ReceptionPage(QWidget):
self.patient_meta_label.setToolTip("")
self.current_assistant_label.setText("当前医助:—")
self.current_assistant_label.setToolTip("")
for key in self.patient_summary_labels:
self._set_patient_summary_value(key, None)
self._set_case_placeholder(message)
self.prescription_hint.setText("处方:患者详情加载失败")
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
@@ -8937,6 +9076,8 @@ class ReceptionPage(QWidget):
for key, label in self.lab_labels.items():
label.setText("—")
self.lab_state_labels[key].setVisible(False)
self._show_missing_labs = False
self._sync_lab_rows()
def _update_action_state(self, appointment: Any, diagnosis: Any) -> None:
appointment_id = _record_id(appointment) or self._selected_appointment_id
@@ -2,11 +2,11 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from string import Template
from typing import Any
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, first_value
from .widgets import SortableTable, TableColumn, first_value
def library_stylesheet() -> str:
@@ -58,7 +58,58 @@ class LibraryComboBox(QComboBox):
class LibraryTable(SortableTable):
"""Repaint existing action widgets and restore selection by template ID."""
"""Fit readable columns while preserving actions and selected template IDs."""
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(columns, parent)
self.horizontalHeader().setStretchLastSection(False)
self._minimum_widths = tuple(column.width for column in self.columns)
self._manual_widths: dict[int, int] = {}
self._fitting_columns = False
self._fit_timer = QTimer(self)
self._fit_timer.setSingleShot(True)
self._fit_timer.timeout.connect(self._fit_columns)
self.horizontalHeader().sectionResized.connect(self._column_resized)
self.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
self._schedule_column_fit()
return super().eventFilter(watched, event)
def _schedule_column_fit(self) -> None:
if not self._fit_timer.isActive():
self._fit_timer.start(0)
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
if self._fitting_columns:
return
self._manual_widths[column] = max(self._minimum_widths[column], size)
self._schedule_column_fit()
def _fit_columns(self) -> None:
widths = [self._manual_widths.get(column, minimum)
for column, minimum in enumerate(self._minimum_widths)]
extra = max(0, self.viewport().width() - sum(widths))
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
if column not in self._manual_widths]
# If every text column was manually sized, keep those preferences as
# minimums and let the herb preview absorb any remaining wide-screen space.
flexible = flexible or [(4, 1)]
total_weight = sum(weight for _column, weight in flexible)
for column, weight in flexible:
widths[column] += extra * weight // total_weight
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.setColumnWidth(column, width)
finally:
self._fitting_columns = False
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
@@ -1,15 +1,10 @@
"""Palette and real font families for the approved reception page only."""
"""Shared palette and typography for the technology-blue workstation pages."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.resources import resource_path
from .theme import _register_preferred_cjk_fonts
TECH_BLUE = {
"accent": "#1769E8",
@@ -30,7 +25,7 @@ TECH_BLUE = {
def _families() -> dict[str, str]:
"""Resolve after Qt starts; font registration does not change its theme."""
"""Use the application face for Chinese, Latin text and numbers alike."""
app = QApplication.instance()
if app is None:
@@ -39,68 +34,28 @@ def _families() -> dict[str, str]:
if cached is not None:
return cached
heading = getattr(app, "_doctor_bundled_font_family", None)
if not heading:
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if registered:
heading = registered[0]
app._doctor_bundled_font_family = heading
available = set(QFontDatabase.families())
# Qt's offscreen platform does not enumerate Windows fonts automatically.
# Register only files already installed on this machine, never substitutes
# downloaded or installed into the user's Windows font registry.
if sys.platform == "win32":
font_dir = (
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
)
for family, filenames in (
("Microsoft YaHei UI", ("msyh.ttc",)),
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
):
if family in available:
continue
for filename in filenames:
font_path = font_dir / filename
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0:
available.update(QFontDatabase.applicationFontFamilies(font_id))
fallback = heading or next(
(
family
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
if family in available
),
app.font().family(),
)
resolved = {
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
"heading": heading or fallback,
"number": "Segoe UI" if "Segoe UI" in available else fallback,
}
# The bundled variable font supplies real Regular, Medium and Semibold
# weights. Keep the same glyph shapes in controls and custom-painted cells;
# hierarchy comes from size and weight within this one family.
family = _register_preferred_cjk_fonts()
resolved = dict.fromkeys(("body", "heading", "number"), family)
app._reception_font_families = resolved
return resolved
def body_family() -> str:
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
"""Shared reading face for controls and table cells."""
return _families()["body"]
def heading_family() -> str:
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
"""The same reading face, with genuine Medium and Semibold weights."""
return _families()["heading"]
def number_family() -> str:
"""Segoe UI supplies Regular and Semibold for numeric labels."""
"""Keep numbers and adjacent Chinese text in the same family."""
return _families()["number"]
@@ -0,0 +1,408 @@
"""Responsive, presentation-only containers for the reception workspace."""
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QResizeEvent
from PySide6.QtWidgets import (
QAbstractScrollArea,
QFrame,
QHBoxLayout,
QLayout,
QScrollArea,
QSizePolicy,
QStackedWidget,
QTabBar,
QVBoxLayout,
QWidget,
)
class ReceptionWorkbench(QWidget):
"""Keep clinical material and insights available at every supported width.
The two scroll areas are moved between layouts when the workspace changes
mode. Their contents, signal connections and selected clinical pages remain
the same objects throughout the lifetime of the workbench.
"""
_WIDE_BREAKPOINT = 760
_LARGE_RAIL_BREAKPOINT = 1100
_UNRESTRICTED_WIDTH = 16_777_215
def __init__(
self,
clinical_scroll: QScrollArea,
insights: QWidget,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("ReceptionWorkbench")
self.setMinimumSize(0, 0)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.clinical_scroll = clinical_scroll
self._wide_mode: bool | None = None
self._relayout_in_progress = False
self.insights_scroll = QScrollArea(self)
self.insights_scroll.setObjectName("ReceptionInsightsScroll")
self.insights_scroll.setAccessibleName("患者资料与 AI 辅助滚动区域")
self.insights_scroll.setWidgetResizable(True)
self.insights_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.insights_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.insights_scroll.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.insights_scroll.setWidget(insights)
for scroll in (self.clinical_scroll, self.insights_scroll):
scroll.setMinimumSize(0, 0)
scroll.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
scroll.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustIgnored)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
# Scroll contents can be arbitrarily tall. They must never dictate the
# outer page's minimum height when the parent layout recalculates.
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
self.mode_tabs = QTabBar(self)
self.mode_tabs.setObjectName("ReceptionWorkspaceTabs")
self.mode_tabs.setAccessibleName("接诊工作区视图")
self.mode_tabs.setDrawBase(False)
self.mode_tabs.setExpanding(False)
self.mode_tabs.addTab("病历资料")
self.mode_tabs.addTab("患者资料与 AI")
self.mode_tabs.currentChanged.connect(self._select_narrow_tab)
layout.addWidget(self.mode_tabs)
self._wide_container = QWidget(self)
self._wide_container.setObjectName("ReceptionWideWorkspace")
self._wide_container.setMinimumSize(0, 0)
self._wide_container.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._wide_layout = QHBoxLayout(self._wide_container)
self._wide_layout.setContentsMargins(0, 0, 0, 0)
self._wide_layout.setSpacing(12)
self._wide_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
layout.addWidget(self._wide_container, 1)
self._narrow_stack = QStackedWidget(self)
self._narrow_stack.setObjectName("ReceptionNarrowWorkspace")
self._narrow_stack.setMinimumSize(0, 0)
self._narrow_stack.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._narrow_stack.layout().setSizeConstraint(
QLayout.SizeConstraint.SetNoConstraint
)
layout.addWidget(self._narrow_stack, 1)
self._update_mode()
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt override
return QSize(390, 220)
def sizeHint(self) -> QSize: # noqa: N802 - Qt override
return QSize(980, 600)
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt override
super().resizeEvent(event)
self._update_mode()
def _select_narrow_tab(self, index: int) -> None:
if self._wide_mode is False and 0 <= index < self._narrow_stack.count():
self._narrow_stack.setCurrentIndex(index)
def _update_mode(self) -> None:
if self._relayout_in_progress:
return
wide = self.width() >= self._WIDE_BREAKPOINT
rail_width = 360 if self.width() >= self._LARGE_RAIL_BREAKPOINT else 320
if self._wide_mode is wide:
if wide and self.insights_scroll.width() != rail_width:
self.insights_scroll.setFixedWidth(rail_width)
return
self._relayout_in_progress = True
try:
self._wide_mode = wide
self._wide_container.hide()
self._narrow_stack.hide()
self.mode_tabs.setVisible(not wide)
if wide:
for scroll in (self.clinical_scroll, self.insights_scroll):
self._narrow_stack.removeWidget(scroll)
self.clinical_scroll.setMinimumWidth(0)
self.insights_scroll.setFixedWidth(rail_width)
self._wide_layout.addWidget(self.clinical_scroll, 1)
self._wide_layout.addWidget(self.insights_scroll, 0)
self.clinical_scroll.show()
self.insights_scroll.show()
self._wide_container.show()
else:
for scroll in (self.clinical_scroll, self.insights_scroll):
self._wide_layout.removeWidget(scroll)
self.insights_scroll.setMinimumWidth(0)
self.insights_scroll.setMaximumWidth(self._UNRESTRICTED_WIDTH)
self._narrow_stack.addWidget(self.clinical_scroll)
self._narrow_stack.addWidget(self.insights_scroll)
self._narrow_stack.setCurrentIndex(self.mode_tabs.currentIndex())
self._narrow_stack.show()
self.updateGeometry()
finally:
self._relayout_in_progress = False
# Append this after RECEPTION_QSS so the page keeps all existing interaction,
# loading and semantic alert states while adopting the workspace's hierarchy.
RECEPTION_WORKSPACE_QSS = r"""
QWidget#ReceptionPage QWidget#ReceptionWorkbench,
QWidget#ReceptionPage QWidget#ReceptionWideWorkspace,
QWidget#ReceptionPage QStackedWidget#ReceptionNarrowWorkspace {
background: transparent;
border: 0;
}
QWidget#ReceptionPage QFrame#ReceptionQueuePanel {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
}
QWidget#ReceptionPage QLabel#ReceptionQueueTitle {
font-size: 18px;
font-weight: 600;
}
QWidget#ReceptionPage QLabel#ReceptionQueueName {
font-size: 15px;
font-weight: 500;
}
QWidget#ReceptionPage QLabel#ReceptionQueueMeta,
QWidget#ReceptionPage QLabel#ReceptionQueueTime,
QWidget#ReceptionPage QLabel#ReceptionQueueSubline,
QWidget#ReceptionPage QLabel#ReceptionQueueSummary {
color: #5D6B80;
font-size: 12px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel#ReceptionQueueMetric {
font-size: 13px;
}
QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="description"] {
color: #5D6B80;
font-size: 12px;
}
QWidget#ReceptionPage QFrame#ReceptionHero,
QWidget#ReceptionPage QFrame#ReceptionClinicalCard {
min-height: 0;
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientName {
font-size: 20px;
font-weight: 600;
}
QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
color: #5D6B80;
font-size: 13px;
}
QWidget#ReceptionPage QLabel#ReceptionAvatar {
color: #1555B6;
background-color: #EAF2FF;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5ECF6;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab {
min-height: 40px;
margin: 0;
padding: 0 14px;
color: #5D6B80;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:first {
margin-left: 0;
padding-left: 14px;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:hover,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab:hover {
color: #1769E8;
background-color: #F3F7FD;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:selected,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab:selected {
color: #1769E8;
background-color: transparent;
border-bottom: 2px solid #1769E8;
font-weight: 500;
}
QWidget#ReceptionPage QFrame#ReceptionHistoryCard {
background-color: #FFFFFF;
border: 0;
border-radius: 0;
}
QWidget#ReceptionPage QWidget#ReceptionCaseField {
background-color: transparent;
border: 0;
border-bottom: 1px solid #EDF1F7;
border-radius: 0;
}
QWidget#ReceptionPage QLabel#ReceptionCaseHeading {
color: #202C3F;
font-size: 16px;
font-weight: 600;
}
QWidget#ReceptionPage QLabel[receptionBlockTitle="true"] {
color: #5D6B80;
font-size: 13px;
font-weight: 500;
}
QWidget#ReceptionPage QLabel[receptionBlockValue="true"] {
font-size: 14px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel[receptionInlineCaption="true"] {
min-width: 0;
color: #5D6B80;
font-size: 12px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel[receptionInlineValue="true"],
QWidget#ReceptionPage QLabel[receptionLifestyleChip="true"] {
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QFrame#ReceptionPatientSummaryCard,
QWidget#ReceptionPage QFrame#ReceptionVitalsCard,
QWidget#ReceptionPage QFrame#ReceptionLabsCard {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue {
color: #24334A;
font-size: 13px;
font-weight: 500;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue[missing="true"] {
color: #697386;
font-weight: 400;
}
QWidget#ReceptionPage QFrame#ReceptionMetricCell,
QWidget#ReceptionPage QFrame#ReceptionMetricCell[firstMetric="true"] {
background-color: transparent;
border: 0;
border-radius: 0;
}
QWidget#ReceptionPage QLabel[receptionMetricCaption="true"],
QWidget#ReceptionPage QLabel[receptionMetricUnit="true"],
QWidget#ReceptionPage QLabel[receptionBmiState="true"] {
color: #5D6B80;
font-size: 12px;
font-weight: 400;
}
QWidget#ReceptionPage QFrame#ReceptionMetricCell QLabel[receptionMetricValue="true"] {
font-size: 18px;
font-weight: 500;
}
QWidget#ReceptionPage QFrame#ReceptionLabCell {
min-height: 0;
padding: 0;
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #EDF1F7;
border-radius: 0;
}
QWidget#ReceptionPage QFrame#ReceptionLabCell QLabel[receptionMetricValue="true"] {
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QPushButton#ReceptionMissingLabsButton {
min-height: 28px;
max-height: 28px;
padding: 0;
color: #1769E8;
background: transparent;
border: 0;
text-align: left;
font-size: 12px;
}
QWidget#ReceptionPage QLabel[receptionMetricState="true"] {
min-height: 18px;
padding: 0 5px;
background-color: #F0F5FD;
border: 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"] {
color: #BE4B58;
background-color: #FFF4F5;
}
QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"][warning="true"] {
color: #A9691D;
background-color: #FFF9EF;
border-radius: 4px;
}
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard,
QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard {
background-color: #FFFFFF;
border: 1px solid #DCE5F0;
border-radius: 8px;
}
QWidget#ReceptionPage QLabel#ReceptionAiTitle,
QWidget#ReceptionPage QPushButton#ReceptionAiTitle {
font-size: 14px;
font-weight: 500;
}
QWidget#ReceptionPage QLabel#ReceptionAiAssistantHint {
color: #5D6B80;
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] {
min-height: 28px;
max-height: 28px;
padding: 0 8px;
font-size: 12px;
border-radius: 6px;
}
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll {
background-color: transparent;
border: 0;
}
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar:vertical {
width: 8px;
margin: 4px 2px;
background: transparent;
}
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::handle:vertical {
min-height: 32px;
background-color: #C4D2E5;
border-radius: 4px;
}
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::add-line:vertical,
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::sub-line:vertical {
height: 0;
}
"""
+31 -25
View File
@@ -72,7 +72,7 @@ from .pages import (
ReceptionPage,
)
from .reception_style import TECH_BLUE, body_family, heading_family, number_family
from .theme import COLORS, crisp_pixmap
from .theme import COLORS, TYPE, crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
@@ -506,13 +506,14 @@ class _AssistantRobot(QWidget):
painter.setPen(Qt.PenStyle.NoPen)
if self.property("receptionTechBlue"):
painter.setBrush(QColor(TECH_BLUE["accent"]))
painter.drawRoundedRect(QRectF(7, 7, 44, 44), 6, 6)
tile = QRectF(6, 6, 36, 36)
painter.drawRoundedRect(tile, 6, 6)
painter.setPen(QColor(TECH_BLUE["surface"]))
font = QFont(number_family())
font.setPixelSize(21)
font.setPixelSize(18)
font.setWeight(QFont.Weight.DemiBold)
painter.setFont(font)
painter.drawText(QRectF(7, 7, 44, 44), Qt.AlignmentFlag.AlignCenter, "AI")
painter.drawText(tile, Qt.AlignmentFlag.AlignCenter, "AI")
return
painter.setBrush(QColor(COLORS["indigo_pale"]))
painter.drawEllipse(QRectF(1, 1, 54, 54))
@@ -562,14 +563,12 @@ class _AssistantStatus(QLabel):
color = QColor(online_color if self._online else "#EC5266")
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
diameter = 9 if reception else 6
diameter = 6
painter.drawEllipse(QRectF(1, (self.height() - diameter) / 2, diameter, diameter))
painter.setPen(color)
font = QFont(self.font())
font.setPixelSize(13 if reception else 10)
painter.setFont(font)
painter.setFont(self.font())
painter.drawText(
QRectF(17 if reception else 12, 0, self.width() - (17 if reception else 12), self.height()),
QRectF(12, 0, self.width() - 12, self.height()),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
self.text(),
)
@@ -1085,7 +1084,7 @@ class ShellWindow(QMainWindow):
font-weight: 600;
}
QLabel#ShellAssistantName { color: #1A1C1F; font-size: 13px; font-weight: 500; }
QLabel#ShellAssistantStatus { color: #287B65; font-size: 13px; }
QLabel#ShellAssistantStatus { color: #287B65; font-size: 12px; }
QPushButton#ShellAssistantButton {
min-height: 34px;
color: #FFFFFF;
@@ -1592,15 +1591,16 @@ class ShellWindow(QMainWindow):
self.brand_layout.setSpacing(13 if active else 10)
self.brand_mark.setFixedSize(38 if active else 36, 38 if active else 36)
self.nav_layout.setContentsMargins(*self._navigation_margins())
self.nav_layout.setSpacing(6 if active else 0)
self.assistant_card.setFixedHeight(171 if active else 157)
self.assistant_layout.setContentsMargins(*(13, 19, 13, 20) if active else (12, 12, 12, 20))
self.nav_layout.setSpacing(4 if active else 0)
self.assistant_card.setFixedHeight(132 if active else 157)
self.assistant_layout.setContentsMargins(*(13, 12, 13, 12) if active else (12, 12, 12, 20))
self.assistant_head_layout.setContentsMargins(4 if active else 0, 0, 0, 0)
self.assistant_head_layout.setSpacing(8 if active else 9)
self.assistant_glyph.setFixedSize(48 if active else 56, 48 if active else 56)
self.assistant_outer_layout.setContentsMargins(*(20, 0, 20, 0) if active else (13, 0, 13, 0))
self.assistant_status.setFixedHeight(22 if active else 18)
self.assistant_button.setFixedHeight(46 if active else 34)
self.upload_settings_button.setFixedHeight(94 if active else 44)
self.assistant_status.setFixedHeight(18)
self.assistant_button.setFixedHeight(36 if active else 34)
self.upload_settings_button.setFixedHeight(52 if active else 44)
self.upload_settings_button.setIcon(_painted_shell_icon("settings", 20, reception=True) if active else QIcon())
self.upload_settings_button.setIconSize(QSize(20, 20) if active else QSize(16, 16))
self.upload_settings_button.setText("设置 ›" if active else "设置 ›")
@@ -1686,15 +1686,15 @@ class ShellWindow(QMainWindow):
}}
QLabel#ShellBrandName {{
font-family: "{heading_family()}"; font-weight: 600;
color: {TECH_BLUE['heading']}; font-size: 18px;
color: {TECH_BLUE['heading']}; font-size: {TYPE['fs_section']};
}}
QLabel#ShellBrandSubtitle {{ color: {TECH_BLUE['text']}; font-size: 13px; }}
QLabel#ShellBrandSubtitle {{ color: {TECH_BLUE['muted']}; font-size: {TYPE['fs_small']}; }}
QPushButton#ShellNavButton {{
font-family: "{body_family()}"; font-weight: 400;
min-height: 52px; max-height: 52px;
min-height: 44px; max-height: 44px;
border: 0; border-left: 5px solid transparent;
border-radius: 6px;
padding: 0 16px; font-size: 15px;
padding: 0 16px; font-size: {TYPE['fs_body']};
color: {TECH_BLUE['text']};
}}
QPushButton#ShellNavButton:checked,
@@ -1715,13 +1715,19 @@ class ShellWindow(QMainWindow):
border: 1px solid {_RECEPTION_CHROME_LINE};
}}
QLabel#ShellAssistantName {{
font-family: "{heading_family()}"; font-weight: 500; font-size: 16px;
font-family: "{heading_family()}"; font-weight: 500; font-size: {TYPE['fs_body']};
}}
QLabel#ShellAssistantStatus {{
font-size: {TYPE['fs_small']};
}}
QPushButton#ShellAssistantButton, QPushButton#ShellSettingsButton {{
font-family: "{body_family()}"; font-weight: 400; font-size: 15px;
font-family: "{body_family()}"; font-weight: 400; font-size: {TYPE['fs_body']};
}}
QPushButton#ShellAssistantButton {{ border-radius: 6px; }}
QPushButton#ShellSettingsButton {{ text-align: left; padding: 0 32px; }}
QPushButton#ShellSettingsButton {{
min-height: 50px; max-height: 50px;
text-align: left; padding: 0 32px;
}}
QPushButton#ShellAssistantButton:focus {{
border: 2px solid {TECH_BLUE['focus']};
}}
@@ -1735,12 +1741,12 @@ class ShellWindow(QMainWindow):
QFrame#ShellGlobalSearch {{ border: 1px solid {_RECEPTION_CHROME_LINE}; border-radius: 5px; }}
QFrame#ShellUserSeparator {{ background: {_RECEPTION_CHROME_LINE}; border: 0; }}
QLabel#ShellShortcutHint {{
font-family: "{number_family()}"; font-size: 13px;
font-family: "{number_family()}"; font-size: {TYPE['fs_small']};
padding: 4px 9px; border-radius: 4px;
border: 1px solid {_RECEPTION_CHROME_LINE};
}}
QLineEdit#ShellGlobalSearchInput {{
color: {TECH_BLUE['text']}; font-size: 15px;
color: {TECH_BLUE['text']}; font-size: {TYPE['fs_body']};
min-height: 40px; max-height: 40px;
}}
QPushButton#ShellAiEntry:pressed {{ background: {TECH_BLUE['selection']}; }}
+2 -1
View File
@@ -85,10 +85,11 @@ COLORS = {
# --- Type scale -----------------------------------------------------------
# Six steps, tuned for 简体中文 at Windows 100%–150% scaling. CJK glyphs carry
# Seven steps, tuned for 简体中文 at Windows 100%–150% scaling. CJK glyphs carry
# more ink than Latin at the same pixel size, so the steps are spaced widely
# enough that two adjacent levels are always distinguishable.
TYPE = {
"fs_small": "12px", # secondary shell copy, status, keyboard hints
"fs_caption": "13px", # table headers, hints, badges, timestamps
"fs_body": "14px", # default UI text
"fs_strong": "15px", # emphasised body, dialog prompts
+431
View File
@@ -0,0 +1,431 @@
from __future__ import annotations
import os
from copy import deepcopy
from dataclasses import FrozenInstanceError
from datetime import datetime
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QToolTip
from doctor_workstation.ui.dialogs.ai_consult_charts import (
GlucoseReportCard,
build_glucose_chart_data,
)
from doctor_workstation.ui.dialogs.ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
UNKNOWN_COLOR,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _row(day: str = "2026-08-18", **changes: Any) -> dict[str, Any]:
return {
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": day,
"fasting_blood_sugar": 6.8,
**changes,
}
def _tracking(*rows: Any, **changes: Any) -> dict[str, Any]:
return {"diagnosis_id": 501, "patient_id": 1501, "blood_records": list(rows), **changes}
def _build(tracking: Any):
return build_glucose_chart_data(tracking, diagnosis_id=501, patient_id=1501)
def test_real_dto_is_immutable_chronological_and_does_not_average_same_day() -> None:
tracking = _tracking(
_row("2026-08-20", record_time="09:10", fasting_blood_sugar="7.2 mmol/L"),
_row("2026-08-18", record_time="09:00", fasting_blood_sugar=6.1),
_row("2026-08-18", record_time="08:00", fasting_blood_sugar=5.9),
_row("2026-08-19", fasting_blood_sugar=None, postprandial_blood_sugar="9.1"),
)
before = deepcopy(tracking)
data = _build(tracking)
assert tracking == before
assert data.available and data.readout_count == 4 and data.record_count == 4
assert [reading.fasting for reading in data.readings] == [5.9, 6.1, None, 7.2]
assert data.readings[2].postprandial == 9.1
assert data.readings[0].recorded_at == datetime(2026, 8, 18, 8)
assert data.readings[0].time_label == "2026-08-18 08:00"
assert data.readings[2].time_label == "2026-08-19"
assert data.date_range is not None
assert tuple(day.isoformat() for day in data.date_range) == ("2026-08-18", "2026-08-20")
assert data.excluded_count == 0
with pytest.raises(FrozenInstanceError):
data.readings[0].fasting = 99 # type: ignore[misc]
tracking["blood_records"][0]["fasting_blood_sugar"] = 99
assert data.readings[-1].fasting == 7.2
@pytest.mark.parametrize("owner", [None, True, 501.0, "501", 502, 0])
def test_tracking_root_requires_exact_diagnosis_owner(owner: Any) -> None:
data = _build(_tracking(_row(), diagnosis_id=owner))
assert not data.available and data.readings == ()
assert "归属" in data.unavailable_reason
def test_ownerless_root_is_rejected() -> None:
tracking = _tracking(_row())
del tracking["diagnosis_id"]
assert not _build(tracking).available
@pytest.mark.parametrize("owner", [None, True, 501.0, "501", 502, 0])
def test_rows_require_exact_diagnosis_owner(owner: Any) -> None:
data = _build(_tracking(_row(diagnosis_id=owner), _row("2026-08-19")))
assert data.record_count == 1
assert data.readings[0].recorded_at.day == 19
assert data.excluded_count == 1
@pytest.mark.parametrize("owner", [None, True, 1501.0, "1501", 1502, 0])
def test_declared_patient_owner_is_strict_at_root_and_row(owner: Any) -> None:
assert not _build(_tracking(_row(), patient_id=owner)).available
data = _build(_tracking(_row(patient_id=owner)))
assert not data.available and data.readings == ()
assert data.excluded_count == 1
def test_diagnosis_owned_rows_may_omit_patient_id_but_not_diagnosis_id() -> None:
row = _row()
del row["patient_id"]
assert _build(_tracking(row)).readout_count == 1
del row["diagnosis_id"]
assert not _build(_tracking(row)).available
def test_root_patient_owner_is_used_when_the_caller_has_no_patient_id() -> None:
data = build_glucose_chart_data(_tracking(_row(patient_id=1502)), diagnosis_id=501)
assert not data.available and data.excluded_count == 1
@pytest.mark.parametrize(
"value",
[
True,
False,
float("nan"),
float("inf"),
-float("inf"),
-1,
"-1",
"6-9",
"约 6.8",
"~6.8",
"<6.8",
"6.8±0.2",
"6.8 mg/dL",
"血糖6.8",
{"value": 6.8},
"Infinity",
],
)
def test_ambiguous_readings_stay_gaps_and_are_never_guessed(value: Any) -> None:
data = _build(_tracking(_row(fasting_blood_sugar=value)))
assert not data.available and data.readout_count == 0
assert len(data.readings) == 1 and data.readings[0].fasting is None
assert data.excluded_count == 1
@pytest.mark.parametrize("value", [None, "", "—", "未填写", 0, 0.0, "0", "0.0 mmol/L"])
def test_daily_zero_and_missing_defaults_are_absent_without_ambiguous_warning(value: Any) -> None:
data = _build(
_tracking(
_row(
fasting_blood_sugar=value,
postprandial_blood_sugar=0,
other_blood_sugar=0,
remark="继续观察",
)
)
)
assert not data.available and data.readout_count == 0
assert data.excluded_count == 0
assert all(reading.readout_count == 0 for reading in data.readings)
@pytest.mark.parametrize("value", ["6.80", "6.8 mmol/L", " 6.8 MMOL / l ", 6.8])
def test_exact_mmol_readings_are_accepted(value: Any) -> None:
data = _build(_tracking(_row(fasting_blood_sugar=value)))
assert data.readings[0].fasting == 6.8
assert data.excluded_count == 0
def test_partial_ambiguous_row_preserves_verified_series_and_records_one_exclusion() -> None:
data = _build(
_tracking(
_row(fasting_blood_sugar="6-9", postprandial_blood_sugar=9.3, other_blood_sugar="约7")
)
)
assert data.available and data.readout_count == 1
assert data.readings[0].fasting is None
assert data.readings[0].postprandial == 9.3
assert data.excluded_count == 1
@pytest.mark.parametrize(
"day", [None, "", "2026-02-30", "08-18", "2026-8-18", "2026-08-18 大约", 1787040000]
)
def test_missing_or_ambiguous_date_never_becomes_today(day: Any) -> None:
data = _build(_tracking(_row(day)))
assert data.readings == () and data.excluded_count == 1
@pytest.mark.parametrize("clock", ["25:10", "8:30", "上午", True])
def test_invalid_declared_record_time_is_excluded(clock: Any) -> None:
assert _build(_tracking(_row(record_time=clock))).excluded_count == 1
def test_legacy_entries_require_owners_and_keep_periods_distinct() -> None:
tracking = {
"diagnosis_id": 501,
"blood_sugar": {
"entries": [
{"diagnosis_id": 501, "date": "2026-08-18", "value": "6.2", "period": "空腹"},
{"diagnosis_id": 501, "date": "2026-08-19", "value": "9.1", "period": "餐后"},
{
"diagnosis_id": 501,
"measured_at": "2026-08-19T20:10:00",
"value": "7.4",
"period": "睡前",
},
{"date": "2026-08-20", "value": "20.2", "period": "空腹"},
]
},
}
data = _build(tracking)
assert data.readout_count == 3 and data.excluded_count == 1
assert data.readings[0].fasting == 6.2
assert data.readings[1].postprandial == 9.1
assert data.readings[2].other == 7.4
assert data.readings[2].time_label == "2026-08-19 20:10"
tracking["blood_sugar"]["diagnosis_id"] = 502
assert not _build(tracking).available
def test_unit_metadata_cannot_relabel_mg_dl_as_mmol() -> None:
assert not _build(_tracking(_row(), unit="mg/dL")).available
assert not _build(_tracking(_row(unit="mg/dL"))).available
assert _build(_tracking(_row(unit="mmol/L"))).available
def test_production_source_takes_precedence_to_avoid_duplicate_representations() -> None:
tracking = _tracking(
_row(), blood_sugar={"entries": [{"diagnosis_id": 501, "date": "2026-08-18", "value": 9.9}]}
)
assert _build(tracking).readout_count == 1
def test_source_states_are_immutable_and_require_the_same_owned_row() -> None:
tracking = _tracking(
_row(fasting_glucose_status="normal", postprandial_glucose=9.3,
postprandial_glucose_status="low", other_glucose=7.1, other_glucose_status="warning"),
_row("2026-08-19", diagnosis_id=502, fasting_blood_sugar_status="high"),
fasting_blood_sugar_status="high",
)
before = deepcopy(tracking)
data = _build(tracking)
assert tracking == before and data.excluded_count == 1
assert len(data.readings) == 1
reading = data.readings[0]
assert (reading.fasting_status, reading.postprandial_status, reading.other_status) == (
"normal", "low", "warning",
)
tracking["blood_records"][0]["fasting_glucose_status"] = "high"
assert reading.fasting_status == "normal"
with pytest.raises(FrozenInstanceError):
reading.fasting_status = "high" # type: ignore[misc]
@pytest.mark.parametrize("status", [None, True, False, ["normal"], {"status": "normal"}])
def test_untyped_glucose_states_stay_unclassified(status: Any) -> None:
data = _build(_tracking(_row(fasting_blood_sugar_status=status)))
assert data.readings[0].fasting_status == ""
def test_glucose_points_tables_and_accessibility_share_explicit_states(application) -> None:
data = _build(_tracking(
_row("2026-08-18", fasting_blood_sugar=20, fasting_blood_sugar_status="normal",
postprandial_blood_sugar=3, postprandial_blood_sugar_status="low",
other_blood_sugar=12, other_blood_sugar_status="unclassified"),
_row("2026-08-19", fasting_blood_sugar=30),
_row("2026-08-20", fasting_blood_sugar=0, fasting_blood_sugar_status="normal"),
))
card = GlucoseReportCard(data)
card.resize(650, 420)
card.show()
application.processEvents()
assert card.chart is not None
chart = card.chart
image = chart.grab().toImage()
first = data.readings[0]
for column, field, color, status in (
(1, "fasting", NORMAL_COLOR, "normal"),
(2, "postprandial", ABNORMAL_COLOR, "low"),
(3, "other", UNKNOWN_COLOR, "unclassified"),
):
point = chart._position(first, getattr(first, field))
assert chart._point_color(first, field) == color
assert image.pixelColor(point.toPoint()).name().casefold() == color.casefold()
assert card.table.item(0, column).foreground().color().name().casefold() == color.casefold()
assert status in card.table.item(0, column).toolTip()
assert status in chart._point_tooltip(point)
assert status in chart.accessibleDescription()
assert card.table.item(1, 1).foreground().color().name() == UNKNOWN_COLOR.casefold()
assert "未标注" in card.table.item(1, 1).toolTip()
assert card.table.item(2, 1).text() == "—"
assert card.table.item(2, 1).foreground().color().name() == UNKNOWN_COLOR.casefold()
assert "未记录" in card.table.item(2, 1).toolTip()
assert "空腹血糖 — · 状态 未记录" in chart.accessibleDescription()
assert "红色异常、绿色正常、灰色未记录/未标注" in chart.accessibleDescription()
card.close()
def test_glucose_connection_is_gray_between_explicit_normal_points(application) -> None:
data = _build(_tracking(
_row("2026-08-18", fasting_blood_sugar=5, fasting_blood_sugar_status="normal"),
_row("2026-08-20", fasting_blood_sugar=5, fasting_blood_sugar_status="normal"),
))
card = GlucoseReportCard(data)
card.resize(640, 420)
card.show()
application.processEvents()
assert card.chart is not None
left, right = [card.chart._position(reading, 5) for reading in data.readings]
midpoint = (left + right) / 2
image = card.chart.grab().toImage()
center = midpoint.toPoint()
assert UNKNOWN_COLOR.casefold() in {
image.pixelColor(center.x() + dx, center.y() + dy).name()
for dx in (-1, 0, 1) for dy in (-1, 0, 1)
}
assert image.pixelColor(left.toPoint()).name() == NORMAL_COLOR.casefold()
card.close()
def test_explicit_missing_series_preserve_gaps_and_actual_date_spacing(
application: QApplication,
) -> None:
data = _build(
_tracking(
_row("2026-08-01", fasting_blood_sugar=5.8),
_row("2026-08-02", fasting_blood_sugar="6-9"),
_row("2026-08-11", fasting_blood_sugar=7.1),
)
)
assert [reading.fasting for reading in data.readings] == [5.8, None, 7.1]
card = GlucoseReportCard(data)
card.resize(700, 400)
card.show()
application.processEvents()
assert card.chart is not None
first = card.chart._position(data.readings[0], 5.8)
gap = card.chart._position(data.readings[1], 5.8)
last = card.chart._position(data.readings[2], 5.8)
assert (gap.x() - first.x()) / (last.x() - first.x()) == pytest.approx(0.1)
assert not card.grab().isNull()
card.close()
def test_single_point_hover_and_original_records_toggle(
application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
data = _build(_tracking(_row(record_time="08:15", fasting_blood_sugar=6.83)))
card = GlucoseReportCard(data)
card.resize(500, 420)
card.show()
application.processEvents()
assert card.objectName() == "AiConsultGlucoseReport"
assert card.chart is not None and card.chart.data is data
assert card.chart.objectName() == "AiConsultGlucoseChart"
assert card.toggle_button is not None
assert card.table.isHidden()
assert card.table.item(0, 0).text() == "2026-08-18 08:15"
assert card.table.item(0, 1).text() == "6.83"
assert card.table.item(0, 2).text() == "—"
assert "6.83 mmol/L" in card.chart.accessibleDescription()
tooltips: list[str] = []
monkeypatch.setattr(QToolTip, "showText", lambda _position, text, *_args: tooltips.append(text))
point = card.chart._position(data.readings[0], 6.83)
QTest.mouseMove(card.chart, QPoint(round(point.x()), round(point.y())))
application.processEvents()
assert tooltips and "2026-08-18 08:15" in tooltips[-1] and "6.83 mmol/L" in tooltips[-1]
QTest.mouseClick(card.toggle_button, Qt.MouseButton.LeftButton)
assert not card.table.isHidden() and card.toggle_button.isChecked()
assert card.toggle_button.text() == "收起原始记录"
QTest.mouseClick(card.toggle_button, Qt.MouseButton.LeftButton)
assert card.table.isHidden()
card.close()
def test_overlapping_observations_remain_separate_in_hover_and_table(
application: QApplication,
) -> None:
data = _build(_tracking(_row(record_time="08:15"), _row(record_time="08:15")))
card = GlucoseReportCard(data)
card.resize(500, 420)
card.show()
application.processEvents()
assert card.chart is not None
tip = card.chart._point_tooltip(card.chart._position(data.readings[0], 6.8))
assert "记录 1" in tip and "记录 2" in tip
assert card.table.rowCount() == 2
card.close()
@pytest.mark.parametrize("width", [320, 580, 880])
def test_report_card_fits_narrow_and_wide_columns(application: QApplication, width: int) -> None:
data = _build(
_tracking(
_row(other_blood_sugar=8.2, postprandial_blood_sugar=9.1),
_row("2026-08-19", fasting_blood_sugar="6-9"),
)
)
card = GlucoseReportCard(data)
card.resize(width, card.sizeHint().height())
card.show()
application.processEvents()
assert card.width() == width
assert card.chart is not None and card.chart.height() >= 210
assert card.chart.geometry().right() < width
for label in card.findChildren(QLabel):
assert label.geometry().right() < width
assert not card.grab().isNull()
assert card.toggle_button is not None
card.toggle_button.click()
application.processEvents()
assert card.table.geometry().right() < width
assert not card.grab().isNull()
card.close()
@pytest.mark.parametrize(
"tracking", [None, {}, _tracking(), _tracking(_row(fasting_blood_sugar=0, remark="仅有备注"))]
)
def test_unavailable_state_has_no_fabricated_chart_or_empty_toggle(
application: QApplication, tracking: Any
) -> None:
card = GlucoseReportCard(_build(tracking))
card.resize(380, card.sizeHint().height())
card.show()
application.processEvents()
assert card.chart is None and card.toggle_button is None and card.table.isHidden()
assert card.height() < 210
assert any("暂无可绘制" in label.text() for label in card.findChildren(QLabel))
assert not card.grab().isNull()
card.close()
+407
View File
@@ -0,0 +1,407 @@
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtGui import QTextCursor, QTextDocument
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.dialogs.ai_consult_metric_text import (
MetricTextState,
metric_text_context,
metric_text_html,
)
@pytest.fixture(scope="module", autouse=True)
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _document(text: str, *, metric_label: str = "") -> QTextDocument:
document = QTextDocument()
document.setHtml(metric_text_html(text, metric_label=metric_label))
assert document.toPlainText() == text.replace("\r\n", "\n").replace("\r", "\n")
return document
def _color(document: QTextDocument, phrase: str, offset: int = 0) -> str:
position = document.toPlainText().index(phrase) + offset
cursor = QTextCursor(document)
cursor.setPosition(position)
cursor.movePosition(QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveMode.KeepAnchor)
return cursor.charFormat().foreground().color().name().upper()
@pytest.mark.parametrize(
"text,measurement",
[
("空腹血糖6-7 mmol/L,餐后8-9mmol/L", "6-7 mmol/L"),
("空腹血糖6-7 mmol/L,餐后8-9mmol/L", "8-9mmol/L"),
("血糖<7.0 mmol/L", "<7.0 mmol/L"),
("血糖≤ 7.0 mmol/L", "≤ 7.0 mmol/L"),
("血糖<= 7.0 mmol/L", "<= 7.0 mmol/L"),
("血糖<7.0 mmol/L", "<7.0 mmol/L"),
("血糖3.9~6.1 mmol/L", "3.9~6.1 mmol/L"),
("血糖4.4至7.0 mmol/L", "4.4至7.0 mmol/L"),
("BP140/90mmHg", "140/90mmHg"),
("血压140 / 90 mmHg", "140 / 90 mmHg"),
("HbA1c7.2%", "7.2%"),
("糖化血红蛋白(HbA1c)7.2%", "7.2%"),
("BMI26.1", "26.1"),
("BMI26.1kg/m²", "26.1kg/m²"),
("体质量指数:26.1", "26.1"),
("身高175cm,体重80 kg", "175cm"),
("身高175cm,体重80 kg", "80 kg"),
("身高1.75m", "1.75m"),
("height175cm,weight80kg", "175cm"),
("height175cm,weight80kg", "80kg"),
("LDL-C130mg/dL", "130mg/dL"),
("TC:5.8mmol/L", "5.8mmol/L"),
("eGFR 85 ml/min/1.73m²", "85 ml/min/1.73m²"),
("eGFR:85", "85"),
("eGFR 85 mL/min/1.73 m2", "85 mL/min/1.73 m2"),
("ACR30mg/g", "30mg/g"),
("尿白蛋白肌酐比值30mg/gCr", "30mg/gCr"),
("肌酐88 μmol/L", "88 μmol/L"),
("尿酸300µmol/L", "300µmol/L"),
("heart_rate:72", "72"),
("heart_rate72bpm", "72bpm"),
("心率72次/分", "72次/分"),
("HR72bpm", "72bpm"),
("血氧饱和度98%", "98%"),
("体温36.8℃", "36.8℃"),
("ALT35U/L", "35U/L"),
("血红蛋白130g/L", "130g/L"),
],
)
def test_clinical_values_are_semibold_gray_and_preserve_all_characters(
text: str, measurement: str
) -> None:
document = _document(text)
for offset in range(len(measurement)):
assert _color(document, measurement, offset) == "#697386"
cursor = QTextCursor(document)
cursor.setPosition(document.toPlainText().index(measurement))
cursor.movePosition(QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveMode.KeepAnchor)
assert cursor.charFormat().fontWeight() >= 600
@pytest.mark.parametrize("cue", ["目标", "参考", "理想", "阈值"])
def test_targets_and_reference_ranges_stay_gray(cue: str) -> None:
document = _document(f"当前血糖8.2 mmol/L,{cue}:<7.0 mmol/L")
assert _color(document, "8.2 mmol/L") == "#697386"
for offset in range(len("<7.0 mmol/L")):
assert _color(document, "<7.0 mmol/L", offset) == "#697386"
def test_percent_targets_inherit_only_a_clinical_percent_label() -> None:
document = _document("HbA1c7.2%,目标<7.0%;总体完成率80%,风险降低20%")
assert _color(document, "7.2%") == "#697386"
assert _color(document, "<7.0%") == "#697386"
assert _color(document, "80%") != "#697386"
assert _color(document, "20%") != "#697386"
def test_current_readings_after_targets_do_not_keep_reference_color() -> None:
document = _document("目标<7.0 mmol/L;当前8.2 mmol/L,6.8 mmol/L(参考值)")
assert _color(document, "<7.0 mmol/L") == "#697386"
assert _color(document, "8.2 mmol/L") == "#697386"
assert _color(document, "6.8 mmol/L") == "#697386"
@pytest.mark.parametrize(
"text",
[
"诊单ID:123456;patient_id=1501;流水ABC123456;电话13800138000",
"2026-08-18,2026/08/19,08:30,第1章,1. 检查,2. 处理",
"病程3年,持续2周,随访7天,每次30分钟",
"二甲双胍500mg,每日2次;胰岛素10U;阿司匹林100 mg",
"剂量0.2mg/kg、浓度5mg/mL,外用2%利多卡因",
"统计显示80%的患者改善20%,目标完成率90%,报销比例50%",
"BMI2026-08-18;eGFR2026/08/19;heart_rate2026-08-18",
"not_BMI26.1;abceGFR85;ID7mmol/L;abc72bpm",
],
)
def test_unrelated_numbers_and_drug_doses_remain_unemphasized(text: str) -> None:
rendered = metric_text_html(text)
_document(text)
assert "color:" not in rendered
def test_explicit_statuses_are_emphasized_without_classifying_the_numbers() -> None:
document = _document("血糖6.0 mmol/L未达标;血压140/90mmHg正常;血糖偏高;检查异常")
assert _color(document, "6.0 mmol/L") == "#BD4B58"
assert _color(document, "140/90mmHg") == "#287B65"
assert _color(document, "未达标") == "#BD4B58"
assert _color(document, "正常") == "#287B65"
assert _color(document, "偏高") == "#BD4B58"
assert _color(document, "异常") == "#BD4B58"
@pytest.mark.parametrize(
"status,color",
[
("未达标", "#BD4B58"),
("不达标", "#BD4B58"),
("尚未完全达标", "#BD4B58"),
("未见异常", "#287B65"),
("无异常", "#287B65"),
("未见明显异常", "#287B65"),
("没有显著异常", "#287B65"),
("不正常", "#BD4B58"),
("非正常", "#BD4B58"),
("未恢复正常", "#BD4B58"),
("已达标", "#287B65"),
],
)
def test_negated_statuses_do_not_color_a_misleading_substring(status: str, color: str) -> None:
document = _document(status)
for offset in range(len(status)):
assert _color(document, status, offset) == color
@pytest.mark.parametrize(
"text",
[
"建议复查以排除异常",
"警惕血糖偏低风险",
"可能偏高,需结合进一步检查",
"若仍未达标,应复查",
"不能排除血糖异常",
"是否正常有待检查",
"未见升高;无明显升高;不偏高",
"无法达标;未达到正常;不能完全达标",
"正常值与正常范围;达标率80%",
"如血糖升高或不达标,应随访",
"需使血糖达标",
"建议控制至正常,争取早日达标",
],
)
def test_uncertain_conditional_or_recommended_statuses_stay_neutral(text: str) -> None:
_document(text)
assert "color:" not in metric_text_html(text)
@pytest.mark.parametrize(
"text",
[
"",
" 原文 保留\t制表符\n\n最后 ",
"\n首行前换行\r\n血糖<7.0 mmol/L\r末行\n",
'<script>alert("test")</script>\n<a href="https://example.com">6-7 mmol/L</a>',
'<img src="x" onerror="alert(1)">HbA1c7.2% &lt;7% &nbsp; & custom',
'<span style="color:red">正常</span> & \'" < >',
],
)
def test_html_is_literal_and_plain_text_and_whitespace_are_preserved(text: str) -> None:
document = _document(text)
html = metric_text_html(text)
assert "<script>" not in html
assert "<a " not in html
assert "<img " not in html
assert '<span style="color:red">' not in html
assert not document.find("https://example.com").charFormat().isAnchor()
assert "\n" not in html
@pytest.mark.parametrize(
"label,text,reading",
[
("空腹血糖", "FPG6.0mmol/L", "6.0mmol/L"),
("FPG", "空腹6.0 mmol/L", "6.0 mmol/L"),
("fasting_glucose", "空腹血糖6.0 mmol/L", "6.0 mmol/L"),
("HbA1c", "糖化血红蛋白7.2%", "7.2%"),
("糖化血红蛋白", "glycated hemoglobin 7.2%", "7.2%"),
("glycated haemoglobin", "HbA1c7.2%", "7.2%"),
("血压", "BP140/90mmHg", "140/90mmHg"),
("LDL-C", "低密度脂蛋白胆固醇3.2 mmol/L", "3.2 mmol/L"),
("尿素氮", "尿素氮6.0 mmol/L", "6.0 mmol/L"),
],
)
def test_recorded_states_match_field_aliases(label: str, text: str, reading: str) -> None:
with metric_text_context([MetricTextState(label, reading, "high")]):
assert _color(_document(text), reading) == "#BD4B58"
@pytest.mark.parametrize(
"status,color",
[
("high", "#BD4B58"),
("low", "#BD4B58"),
("warning", "#BD4B58"),
("未达标", "#BD4B58"),
("偏低", "#BD4B58"),
("normal", "#287B65"),
("达标", "#287B65"),
("unknown", "#697386"),
("", "#697386"),
("未记录", "#697386"),
("unexpected", "#697386"),
],
)
def test_value_only_widgets_use_the_supplied_field(status: str, color: str) -> None:
with metric_text_context([MetricTextState("FPG", "6.0 mmol/L", status)]):
document = _document("6.0 mmol/L", metric_label="空腹血糖")
assert _color(document, "6.0 mmol/L") == color
assert _color(_document("6.0 mmol/L"), "6.0 mmol/L") == "#697386"
def test_unitless_value_only_widgets_bind_without_classifying_other_numbers() -> None:
with metric_text_context([MetricTextState("BMI", "26.1", "normal")]):
assert _color(_document("26.1", metric_label="体质指数"), "26.1") == "#287B65"
assert "color:" not in metric_text_html("2026-08-18", metric_label="BMI")
def test_context_restores_outer_state_after_nested_scope_and_exception() -> None:
text = "空腹血糖6.0 mmol/L"
reading = "6.0 mmol/L"
with metric_text_context([MetricTextState("FPG", reading, "high")]):
assert _color(_document(text), reading) == "#BD4B58"
with (
pytest.raises(RuntimeError),
metric_text_context([MetricTextState("空腹", reading, "normal")]),
):
assert _color(_document(text), reading) == "#287B65"
raise RuntimeError("restore scope")
assert _color(_document(text), reading) == "#BD4B58"
assert _color(_document(text), reading) == "#697386"
def test_context_snapshots_input_sequence() -> None:
states = [MetricTextState("空腹", "6.0 mmol/L", "high")]
with metric_text_context(states):
states.clear()
assert _color(_document("空腹6.0 mmol/L"), "6.0 mmol/L") == "#BD4B58"
@pytest.mark.parametrize("statuses", [("high", "normal"), ("unknown", "normal"), ("high", "")])
def test_conflicting_same_field_and_reading_states_stay_gray(statuses: tuple[str, str]) -> None:
with metric_text_context(
[
MetricTextState("FPG", "6.0 mmol/L", statuses[0]),
MetricTextState("空腹血糖", "6.0mmol/L", statuses[1]),
]
):
assert _color(_document("空腹血糖6.0 mmol/L正常"), "6.0 mmol/L") == "#697386"
def test_equivalent_abnormal_source_flags_do_not_conflict() -> None:
with metric_text_context(
[
MetricTextState("FPG", "6.0 mmol/L", "high"),
MetricTextState("空腹血糖", "6.0mmol/L", "warning"),
]
):
assert _color(_document("空腹血糖6.0 mmol/L"), "6.0 mmol/L") == "#BD4B58"
@pytest.mark.parametrize(
"text",
[
"餐后血糖6.0 mmol/L",
"血糖6.0 mmol/L",
"空腹血糖6.1 mmol/L",
"空腹血糖6.0 mg/dL",
"空腹血糖<6.0 mmol/L",
"空腹血糖6.0-7.0 mmol/L",
],
)
def test_matching_value_cannot_leak_between_fields_units_or_readings(text: str) -> None:
with metric_text_context([MetricTextState("FPG", "6.0 mmol/L", "normal")]):
assert "#287B65" not in metric_text_html(text)
def test_range_source_requires_whole_range_and_comparator() -> None:
reading = "≤ 4.4至7.0 mmol/L"
with metric_text_context([MetricTextState("空腹", reading, "warning")]):
assert _color(_document(f"空腹血糖{reading}"), reading) == "#BD4B58"
assert _color(_document("空腹血糖4.4 mmol/L"), "4.4 mmol/L") == "#697386"
def test_known_source_state_takes_precedence_over_ai_assertion() -> None:
with metric_text_context([MetricTextState("空腹血糖", "6.0 mmol/L", "high")]):
document = _document("空腹血糖6.0 mmol/L正常")
assert _color(document, "6.0 mmol/L") == "#BD4B58"
assert _color(document, "正常") == "#287B65"
@pytest.mark.parametrize("hint", ["", "空腹血糖", "空腹血糖目标"])
def test_targets_never_inherit_source_state_even_when_value_matches(hint: str) -> None:
with metric_text_context([MetricTextState("空腹血糖", "6.0 mmol/L", "high")]):
document = _document("目标空腹血糖6.0 mmol/L", metric_label=hint)
assert _color(document, "6.0 mmol/L") == "#697386"
document = _document("6.0 mmol/L(参考值)", metric_label=hint)
assert _color(document, "6.0 mmol/L") == "#697386"
def test_percent_reference_and_same_value_source_are_separated() -> None:
with metric_text_context([MetricTextState("HbA1c", "7.2%", "high")]):
document = _document("糖化血红蛋白7.2%,目标7.2%")
assert _color(document, "7.2%") == "#BD4B58"
cursor = document.find("目标7.2%")
cursor.setPosition(cursor.selectionEnd() - 2)
cursor.movePosition(
QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveMode.KeepAnchor
)
assert cursor.charFormat().foreground().color().name().upper() == "#697386"
def test_percent_body_uses_its_separate_label_without_coloring_unrelated_percentages() -> None:
with metric_text_context([MetricTextState("HbA1c", "7.2%", "high")]):
document = _document("7.2%,目标7.2%;完成率80%,风险下降20%", metric_label="HbA1c")
assert _color(document, "7.2%") == "#BD4B58"
assert _color(document, "80%") != "#697386"
assert _color(document, "20%") != "#697386"
@pytest.mark.parametrize("cue", ["正常范围", "target", "reference range"])
def test_reference_phrases_always_stay_gray(cue: str) -> None:
with metric_text_context([MetricTextState("HbA1c", "7.2%", "high")]):
document = _document(f"HbA1c {cue}: 7.2%")
assert _color(document, "7.2%") == "#697386"
@pytest.mark.parametrize(
"text,reading,color",
[
("血糖6.0 mmol/L未达标", "6.0 mmol/L", "#BD4B58"),
("血压140/90mmHg正常", "140/90mmHg", "#287B65"),
("血糖偏高:6.0 mmol/L", "6.0 mmol/L", "#BD4B58"),
("空腹6.0 mmol/L偏低", "6.0 mmol/L", "#BD4B58"),
("BMI26.1达标", "26.1", "#287B65"),
("FPG6.0mmol/L high", "6.0mmol/L", "#BD4B58"),
("血糖6.0 mmol/L可能偏高", "6.0 mmol/L", "#697386"),
("若血糖6.0 mmol/L未达标", "6.0 mmol/L", "#697386"),
("血糖6.0 mmol/L无明显升高", "6.0 mmol/L", "#697386"),
("血糖6.0 mmol/L血压正常", "6.0 mmol/L", "#697386"),
("血糖6.0 mmol/L血压140/90mmHg正常", "6.0 mmol/L", "#697386"),
],
)
def test_local_assertion_colors_only_its_nearby_reading(
text: str, reading: str, color: str
) -> None:
assert _color(_document(text), reading) == color
@pytest.mark.parametrize(
"missing", ["未记录", "未检测", "未知", "暂无数据", "unknown", "N/A", "—", "--", "/"]
)
def test_missing_values_are_gray(missing: str) -> None:
with metric_text_context([MetricTextState("空腹血糖", missing, "normal")]):
assert _color(_document(missing, metric_label="空腹血糖"), missing) == "#697386"
@pytest.mark.parametrize(
"label,color", [("高风险", "#BD4B58"), ("中风险", "#BD4B58"), ("低风险", "#287B65")]
)
def test_asserted_risk_levels_use_the_chart_palette(label: str, color: str) -> None:
assert _color(_document(f"风险评估:{label}"), label) == color
@pytest.mark.parametrize("text", ["可能高风险", "排除高风险", "中风险参考标准", "若为低风险"])
def test_conditional_risk_or_reference_is_not_an_asserted_state(text: str) -> None:
assert "#BD4B58" not in metric_text_html(text)
assert "#287B65" not in metric_text_html(text)
@@ -0,0 +1,413 @@
"""Recorded glucose charts inside the actual AI report and streaming lifecycle."""
from __future__ import annotations
import os
from copy import deepcopy
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent, QPoint, QTimer
from PySide6.QtGui import QTextDocument
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QComboBox, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult as module
from doctor_workstation.ui.dialogs.ai_consult_charts import GlucoseReportCard
REPORT = """以下依据已加载的血糖记录梳理变化情况,请结合原始测量时间与当前病历逐项核对。
1. 血糖记录与趋势
- 记录核对:请区分空腹和餐后记录,并结合实际测量日期阅读图表,缺失的记录不能作为零值。
- 资料说明:文字中提及的数值仅用于说明,图表只能采用健康档案中的实际测量记录。
2. 随访资料核对
- 本次复诊:请保留完整记录,核对服药时间、进食时间与居家测量方式,补充尚未填写的资料。
"""
CLINICAL_REPORT = """基于当前病历,患者有血糖波动的描述,需结合完整的测量记录与四诊信息进一步核对。
### 1. 可能证候分析
- 病史待核对:现有记录反映的变化需与实际测量条件相互核对,尚不足以确认具体证候。
### 2. 关键矛盾与不足
- 资料缺口:需区分空腹及餐后记录,核对测量日期,保留未记录的部分而不补写数值。
### 3. 建议下一步
- 核对原始记录:将历史血糖记录与用药、进食情况一起核对,由医生结合资料作进一步判断。
"""
def _tracking(diagnosis_id=501, patient_id=1501):
return {
"diagnosis_id": diagnosis_id,
"blood_records": [
{"diagnosis_id": diagnosis_id, "patient_id": patient_id,
"record_date": "2026-10-17", "record_time": "07:30",
"fasting_blood_sugar": 6.2, "postprandial_blood_sugar": 8.4},
{"diagnosis_id": diagnosis_id, "patient_id": patient_id,
"record_date": "2026-10-18", "record_time": "07:40",
"fasting_blood_sugar": 6.8, "postprandial_blood_sugar": None},
],
}
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
def _settle(application):
for _ in range(6):
application.processEvents()
QTest.qWait(5)
@pytest.fixture
def dialog(application, monkeypatch):
instance = module.AiConsultDialog(object(), PermissionSet(["tcm.diagnosis/aiAssistant"]))
monkeypatch.setattr(instance, "_load_workspace", lambda _generation: None)
instance.open_for(diagnosis_id=501, patient_id=1501)
instance._workspace_sections = {"tracking": {"value": _tracking()}}
instance.resize(1280, 820)
instance.show()
_settle(application)
yield instance
for timer in instance.findChildren(QTimer):
timer.stop()
instance.close()
instance.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()
@pytest.mark.parametrize("reply, panel_type", [
(REPORT, module._StructuredReportPanel),
(CLINICAL_REPORT, module._ClinicalAnalysisPanel),
])
def test_completed_report_formats_use_observed_measurements(dialog, reply, panel_type):
bubble = dialog._append_bubble("ai", reply)
before = deepcopy(dialog._workspace_sections)
dialog._finalize_ai_bubble(bubble)
panel = bubble.findChild(panel_type)
assert panel is not None
card = panel.findChild(GlucoseReportCard)
assert card is not None and card.chart is not None
assert card.chart.data.readout_count == 3
assert [row.fasting for row in card.chart.data.readings] == [6.2, 6.8]
assert [row.postprandial for row in card.chart.data.readings] == [8.4, None]
assert dialog._workspace_sections == before
# The chart is above the textual interpretation, not a separate chat reply.
assert panel.layout().itemAt(1).widget() is card
assert bubble.body is not None and bubble.body.isHidden()
assert bubble._raw_payload == reply
def test_stream_builds_chart_on_done_once_and_keeps_timing_and_snapshot(dialog, application):
bubble = dialog._append_bubble("ai", "")
dialog._stream_bubble = bubble
dialog._stream_prompt = "请评估该患者的血糖控制情况与趋势。"
generation, stream = dialog._generation, dialog._stream_generation
dialog._stream_event(generation, stream, {"event": "delta", "text": REPORT})
dialog._flush_stream_chunks()
assert bubble.findChild(GlucoseReportCard) is None
dialog._stream_event(generation, stream, {"event": "done", "model_label": "千问"})
_settle(application)
cards = bubble.findChildren(GlucoseReportCard)
assert len(cards) == 1 and cards[0].chart is not None
assert cards[0].chart.data.readout_count == 3
panel = bubble.findChild(module._StructuredReportPanel)
assert panel is not None
dialog._stream_finished(generation, stream, object())
assert "千问" in panel.meta_label.text()
assert len(bubble.findChildren(GlucoseReportCard)) == 1
dialog._workspace_sections["tracking"]["value"]["blood_records"][0]["fasting_blood_sugar"] = 22
assert cards[0].chart.data.readings[0].fasting == 6.2
assert dialog.send_button.isEnabled()
def test_archived_and_saved_reports_receive_the_same_chart(dialog):
dialog._render_messages([{"diagnosis_id": 501, "role": "ai", "content": REPORT}])
archived = dialog.findChildren(GlucoseReportCard)
assert len(archived) == 1 and archived[0].chart is not None
dialog._render_analysis({"diagnosis_advice": "血糖记录需与实际进食时间核对。"})
charts = [card.chart for card in dialog.findChildren(GlucoseReportCard)]
assert len(charts) == 2
assert all(chart is not None and chart.data.readout_count == 3 for chart in charts)
def test_no_measurements_keeps_full_report_without_fabricated_prose_values(dialog):
dialog._workspace_sections = {"tracking": {"value": None, "error": "记录不可用"}}
reply = REPORT + "\nAI 文本举例:空腹血糖 5.6 mmol/L,餐后 8.2 mmol/L。"
bubble = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(bubble)
card = bubble.findChild(GlucoseReportCard)
assert card is None
assert bubble._raw_payload == reply
assert bubble.findChild(module._StructuredReportPanel) is not None
assert bubble.findChild(module.QFrame, "AiConsultReportVisual") is None
def test_plain_answer_gets_chart_but_irrelevant_answer_stays_text_only(dialog):
bubble = dialog._append_bubble("ai", "请核对原始血糖记录。")
dialog._finalize_ai_bubble(bubble)
assert bubble.findChild(GlucoseReportCard) is not None
assert bubble.body is not None and not bubble.body.isHidden()
unrelated = dialog._append_bubble("ai", "请携带既往处方核对具体药物名称。")
dialog._finalize_ai_bubble(unrelated)
assert unrelated.findChild(GlucoseReportCard) is None
def test_switching_patient_clears_chart_context_before_loading_and_ignores_old_stream(dialog):
old_generation, old_stream = dialog._generation, dialog._stream_generation
dialog.open_for(diagnosis_id=502, patient_id=1502)
assert dialog._workspace_sections == {}
assert dialog._detail == {}
bubble = dialog._append_bubble("ai", REPORT)
dialog._finalize_ai_bubble(bubble)
card = bubble.findChild(GlucoseReportCard)
assert card is None
dialog._stream_bubble = bubble
dialog._stream_event(old_generation, old_stream, {"event": "done", "answer": REPORT})
assert bubble.findChild(GlucoseReportCard) is card
# A delayed response for the previous diagnosis cannot repopulate data.
dialog._workspace_loaded(old_generation, {"diagnosis_id": 501, "tracking": {"value": _tracking()}})
assert dialog._workspace_sections == {}
def test_changed_authoritative_patient_cannot_enable_old_chart_data(dialog):
dialog.patient_id = 1999
bubble = dialog._append_bubble("ai", REPORT)
dialog._finalize_ai_bubble(bubble)
card = bubble.findChild(GlucoseReportCard)
assert card is None
@pytest.mark.parametrize("width", [1080, 1600])
def test_report_chart_and_end_of_report_remain_reachable(dialog, application, width):
dialog.resize(width, 820)
bubble = dialog._append_bubble("ai", REPORT)
dialog._finalize_ai_bubble(bubble)
_settle(application)
card = bubble.findChild(GlucoseReportCard)
assert card is not None and card.chart is not None
assert card.chart.width() > 200 and card.chart.height() > 150
assert dialog.chat_scroll.horizontalScrollBar().maximum() == 0
assert card.chart.width() <= card.width()
assert bubble._clinical_panel is not None
final_label = bubble._clinical_panel.findChildren(QLabel, "AiConsultReportBody")[-1]
dialog.chat_scroll.verticalScrollBar().setValue(dialog.chat_scroll.verticalScrollBar().maximum())
_settle(application)
bottom = final_label.mapTo(dialog.chat_scroll.viewport(), QPoint(0, final_label.height() - 1))
assert dialog.chat_scroll.viewport().rect().contains(bottom)
def test_blood_pressure_answer_automatically_chooses_pressure_instead_of_glucose(dialog):
for index, row in enumerate(dialog._workspace_sections["tracking"]["value"]["blood_records"]):
row.update(systolic_pressure=142 - index * 4, diastolic_pressure=90 - index * 2)
reply = """以下根据本诊单实际录入的血压记录梳理近期变化,需结合原始测量时间核对。
1. 血压记录与变化
- 记录核对:当前已有连续日期的收缩压和舒张压记录,图表应按实际日期显示,保留原有测量单位。
2. 随访资料核对
- 测量条件:请核对测量时的休息状态、服药时间与设备,结合完整资料由医生进一步评估。
"""
bubble = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(bubble, prompt="请分析血压变化。")
assert bubble.findChild(GlucoseReportCard) is None
cards = bubble.findChildren(module.QFrame, "AiConsultReportVisual")
assert cards and any(card.property("visualKind") == "blood_pressure" for card in cards)
assert all(card.data.kind == card.property("visualKind") for card in cards)
def test_numeric_ranges_and_status_are_colored_without_changing_report_text(dialog):
reply = """以下根据患者描述核对现有血糖记录,本段数字范围需要保留原文,不能自动改成精确测量点。
1. 当前控制水平:未达标
- 空腹血糖:记录为 6-7 mmol/L,需结合原始测量时间核对。
- 餐后血糖:记录为 8-9 mmol/L,当前资料尚需进一步核对。
2. 参考目标与复诊资料
- 目标说明:目标 <7.0 mmol/L 仅为报告中引用的参考内容,应与实际记录区分。
"""
dialog._workspace_sections = {}
bubble = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(bubble)
assert bubble.findChild(GlucoseReportCard) is None
panel = bubble.findChild(module._StructuredReportPanel)
assert panel is not None
documents = []
for label in panel.findChildren(QLabel):
doc = QTextDocument()
doc.setHtml(label.text())
documents.append(doc)
for value in ("6-7 mmol/L", "8-9 mmol/L", "<7.0 mmol/L"):
document = next(doc for doc in documents if value in doc.toPlainText())
color = document.find(value).charFormat().foreground().color().name()
assert color not in {"#000000", "#1a1c1f"}
heading = next(doc for doc in documents if "当前控制水平" in doc.toPlainText())
assert heading.find("未达标").charFormat().foreground().color().name() == "#bd4b58"
assert bubble._raw_payload == reply
def test_refinalizing_answer_replaces_visuals_without_duplicate_cards(dialog, application):
bubble = dialog._append_bubble("ai", REPORT)
dialog._finalize_ai_bubble(bubble)
assert len(bubble.findChildren(GlucoseReportCard)) == 1
dialog._finalize_ai_bubble(bubble)
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert len(bubble.findChildren(GlucoseReportCard)) == 1
dialog._workspace_sections = {}
dialog._finalize_ai_bubble(bubble)
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert bubble.findChild(GlucoseReportCard) is None
def test_short_answer_colors_values_without_replacing_markdown_or_original_payload(dialog):
reply = "**复核记录**:血压 142/90 mmHg,HbA1c 7.2%,请结合实际资料核对。"
bubble = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(bubble)
assert bubble.body is not None
document = bubble.body.document()
assert document.find("142/90 mmHg").charFormat().foreground().color().name() == "#697386"
assert document.find("复核记录").charFormat().fontWeight() >= 600
assert bubble._raw_payload == reply
def test_clinical_evidence_keeps_ranges_and_does_not_shorten_approximate_readings():
evidence = module._clinical_evidence("患者空腹血糖 6-7 mmol/L,其他资料待补充。")
assert evidence[0].value == "6-7 mmol/L"
approximate = module._clinical_evidence("患者空腹血糖有时6点多,尚未携带原始记录。")
assert not any(item.label == "空腹血糖" for item in approximate)
def test_multitopic_reports_switch_charts_without_hiding_or_rebuilding_analysis(dialog, application):
dialog._workspace_sections["detail"] = {"value": {"diagnosis": {
"id": 501, "patient_id": 1501, "hba1c": 7.2, "hba1c_status": "high",
}, "patient": {"id": 1501, "weight": 68, "height": 168}}}
for index, row in enumerate(dialog._workspace_sections["tracking"]["value"]["blood_records"]):
row.update(systolic_pressure=142 - index * 4, diastolic_pressure=90 - index * 2)
bubble = dialog._append_bubble("ai", REPORT)
dialog._finalize_ai_bubble(bubble, prompt="请综合分析血糖、血压和体重。")
_settle(application)
selector = bubble.findChild(QComboBox, "AiConsultReportVisualSelector")
assert selector is not None and selector.count() == 3
group = selector.parentWidget()
cards = [item.widget() for index in range(1, group.layout().count())
if (item := group.layout().itemAt(index)).widget() is not None]
assert len(cards) == 3
identities = [id(card) for card in cards]
for index in range(selector.count()):
selector.setCurrentIndex(index)
_settle(application)
assert not cards[index].isHidden()
assert all(card.isHidden() for position, card in enumerate(cards) if position != index)
assert [id(card) for card in cards] == identities
assert dialog.chat_scroll.horizontalScrollBar().maximum() == 0
assert bubble._raw_payload == REPORT
assert bubble.findChild(module._StructuredReportPanel) is not None
def _label_color(widget, reading, object_name=""):
documents = []
for label in widget.findChildren(QLabel, object_name):
document = QTextDocument()
document.setHtml(label.text())
if reading in document.toPlainText():
documents.append(document)
assert documents, f"No rendered label contains {reading!r}"
return {doc.find(reading).charFormat().foreground().color().name() for doc in documents}
def _colored_snapshot():
return {"detail": {"value": {"diagnosis": {
"id": 501, "patient_id": 1501,
"fasting_blood_sugar": "6-7", "fasting_blood_sugar_status": "high",
"postprandial_blood_sugar": "8-9", "postprandial_blood_sugar_status": "normal",
"hba1c": 7.2, "bmi": 26.1, "bmi_status": "high",
}, "patient": {"id": 1501}}}}
def test_streamed_snapshot_uses_same_colors_in_body_chart_and_raw_records(dialog):
dialog._workspace_sections = _colored_snapshot()
before = deepcopy(dialog._workspace_sections)
reply = """以下根据本诊单录入的指标核对资料,范围保留原文,由医生结合原始记录评估。
1. 本次指标记录
- 空腹血糖:记录为 6-7 mmol/L,保留原始范围。
- 餐后血糖:记录为 8-9 mmol/L,保留原始范围。
- 糖化血红蛋白:7.2%,记录未标注状态。
2. 补充资料
- 参考目标:空腹血糖目标 6-7 mmol/L,仅供核对报告中的参考内容。
"""
bubble = dialog._append_bubble("ai", "")
dialog._stream_bubble = bubble
dialog._stream_prompt = "综合分析血糖"
generation, stream = dialog._generation, dialog._stream_generation
dialog._stream_event(generation, stream, {"event": "delta", "text": reply})
dialog._stream_event(generation, stream, {"event": "done"})
panel = bubble.findChild(module._StructuredReportPanel)
assert panel is not None
assert _label_color(panel, "6-7 mmol/L", "AiConsultReportBody") == {"#bd4b58", "#697386"}
assert _label_color(panel, "8-9 mmol/L", "AiConsultReportBody") == {"#287b65"}
assert _label_color(panel, "7.2%", "AiConsultReportBody") == {"#697386"}
card = next(card for card in panel.findChildren(module.QFrame, "AiConsultReportVisual")
if card.data.kind == "metrics")
for label, color in [("空腹血糖", "#bd4b58"), ("餐后血糖", "#287b65"), ("糖化血红蛋白", "#697386")]:
index, item = next((i, item) for i, item in enumerate(card.data.items) if item.label == label)
assert item.color.lower() == color
assert card.table.item(index, 2).foreground().color().name() == color
assert bubble._raw_payload == reply
assert dialog._workspace_sections == before
def test_trend_states_also_color_short_markdown_and_combined_pressure(dialog):
rows = dialog._workspace_sections["tracking"]["value"]["blood_records"]
rows[0].update(fasting_blood_sugar_status="high", postprandial_blood_sugar_status="normal",
systolic_pressure=142, diastolic_pressure=90,
systolic_pressure_status="normal", diastolic_pressure_status="high")
reply = "**核对记录**:空腹血糖 6.2 mmol/L,餐后血糖 8.4 mmol/L,血压 142/90 mmHg。"
bubble = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(bubble)
document = bubble.body.document()
for reading, color in [("6.2 mmol/L", "#bd4b58"), ("8.4 mmol/L", "#287b65"), ("142/90 mmHg", "#bd4b58")]:
assert document.find(reading).charFormat().foreground().color().name() == color
assert document.toPlainText() == "核对记录:空腹血糖 6.2 mmol/L,餐后血糖 8.4 mmol/L,血压 142/90 mmHg。"
assert document.find("核对记录").charFormat().fontWeight() >= 600
# Rendering a different record cannot inherit the previous report's source flags.
dialog._workspace_sections = {}
other = dialog._append_bubble("ai", reply)
dialog._finalize_ai_bubble(other)
assert other.body.document().find("6.2 mmol/L").charFormat().foreground().color().name() == "#697386"
assert document.find("6.2 mmol/L").charFormat().foreground().color().name() == "#bd4b58"
def test_saved_summary_and_key_facts_share_source_state_and_missing_palette(dialog):
dialog._workspace_sections = _colored_snapshot()
dialog._workspace_sections["detail"]["value"]["diagnosis"]["hba1c_status"] = "high"
dialog._apply_header(dialog._workspace_sections["detail"]["value"])
facts = dialog.findChildren(QLabel, "AiConsultKeyValue")
assert next(label for label in facts if label.text() == "26.1").styleSheet() == "color: #bd4b58;"
assert all(label.styleSheet() == "color: #697386;" for label in facts if label.text() == "未记录")
dialog._render_analysis({
"diagnosis_advice": "空腹血糖 6-7 mmol/L,餐后血糖 8-9 mmol/L。HbA1c 7.2%。",
"risk_assessment": [{"label": "随访风险", "level": "medium"}],
})
assert _label_color(dialog, "6-7 mmol/L", "AiConsultInsightText") == {"#bd4b58"}
assert _label_color(dialog, "8-9 mmol/L", "AiConsultInsightText") == {"#287b65"}
assert _label_color(dialog, "7.2%", "AiConsultInsightText") == {"#bd4b58"}
badge = next(label for label in dialog.findChildren(QLabel, "AiConsultTag") if label.text() == "中风险")
assert "#BD4B58" in badge.styleSheet()
@pytest.mark.parametrize("text", ["HbA1c 7.2%", "7.2 mmol/L 为原始记录", "指标:7.2%"])
def test_sidebar_does_not_split_decimal_measurements_into_list_markers(text):
segments = module._split_insight_segments(text)
assert len(segments) == 1
assert segments[0].kind != "list" and "7.2" in segments[0].text
def test_numbered_sidebar_summary_preserves_decimals_and_actual_list_structure():
segments = module._split_insight_segments("建议:1. 复核 HbA1c 7.2% 2. 核对空腹血糖 6.8 mmol/L")
numbered = [item for item in segments if item.kind == "list"]
assert [(item.marker, item.text) for item in numbered] == [
("1.", "复核 HbA1c 7.2%"), ("2.", "核对空腹血糖 6.8 mmol/L"),
]
+571
View File
@@ -0,0 +1,571 @@
from __future__ import annotations
import os
from copy import deepcopy
from dataclasses import FrozenInstanceError, replace
from datetime import datetime
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPointF, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QToolTip
from doctor_workstation.core.models import Prescription
from doctor_workstation.ui.dialogs.ai_consult_charts import GlucoseChartData, GlucoseReportCard
from doctor_workstation.ui.dialogs.ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
UNKNOWN_COLOR,
)
from doctor_workstation.ui.dialogs.ai_consult_report_visuals import (
ReportVisualCard,
build_report_visuals,
create_report_visual,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _row(day: str = "2026-08-18", **changes: Any) -> dict[str, Any]:
return {
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": day,
"fasting_blood_sugar": 6.8,
"systolic_pressure": 146,
"diastolic_pressure": 92,
**changes,
}
def _sections(*rows: Any, **diagnosis: Any) -> dict[str, Any]:
return {
"detail": {"value": {"diagnosis": {"id": 501, "patient_id": 1501, **diagnosis}}},
"tracking": {
"value": {"diagnosis_id": 501, "patient_id": 1501, "blood_records": list(rows)}
},
}
def _build(sections: dict[str, Any], prompt: str = "分析血压", reply: str = ""):
return build_report_visuals(prompt, reply, sections=sections, diagnosis_id=501, patient_id=1501)
def _kind(data: Any) -> str:
return "glucose" if isinstance(data, GlucoseChartData) else data.kind
def test_selects_actual_topics_in_prompt_or_reply_without_unrelated_charts() -> None:
sections = _sections(_row(), weight=60, hba1c=7.2)
assert [_kind(item) for item in _build(sections)] == ["blood_pressure"]
assert [_kind(item) for item in _build(sections, "", "检查空腹血糖")] == ["glucose", "metrics"]
assert _build(sections, "解释睡眠质量") == ()
assert [_kind(item) for item in _build(sections, "综合分析")] == [
"glucose",
"blood_pressure",
"metrics",
]
@pytest.mark.parametrize(
"topic", ["血糖", "血压", "血脂", "体重", "肾功能", "风险", "用药", "运动", "综合分析"]
)
def test_no_actual_data_never_produces_chart_shell(topic: str) -> None:
assert _build({}, topic, "目标血压 120/80,HbA1c 6.5%,每日运动30分钟") == ()
assert _build(_sections(), topic) == ()
def test_ai_prose_goals_and_approximate_numbers_never_become_observations() -> None:
sections = _sections(
_row(systolic_pressure="约146", diastolic_pressure="90-95", fasting_blood_sugar="~6.8")
)
assert _build(sections, "分析血压和血糖", "今天146/92,目标120/80,血糖7.0") == ()
@pytest.mark.parametrize("owner", [None, True, "501", 501.0, 0, 502])
def test_tracking_root_and_rows_need_exact_diagnosis_owner(owner: Any) -> None:
root = _sections(_row())
root["tracking"]["value"]["diagnosis_id"] = owner
assert _build(root) == ()
rows = _sections(_row(diagnosis_id=owner), _row("2026-08-19"))
visual = _build(rows)[0]
assert len(visual.items) == 2
assert all(item.recorded_at.day == 19 for item in visual.items)
@pytest.mark.parametrize("owner", [None, True, "1501", 1501.0, 0, 1502])
def test_declared_patient_owner_is_checked_at_tracking_root_and_row(owner: Any) -> None:
root = _sections(_row())
root["tracking"]["value"]["patient_id"] = owner
assert _build(root) == ()
assert _build(_sections(_row(patient_id=owner))) == ()
@pytest.mark.parametrize("owner", [None, True, "501", 501.0, 502])
def test_mismatched_detail_disables_linked_visuals(owner: Any) -> None:
sections = _sections(_row(), id=owner, hba1c=7.2)
assert _build(sections, "综合分析") == ()
def test_detail_patient_conflicts_and_section_errors_fail_closed() -> None:
assert _build(_sections(_row(), patient_id=1502)) == ()
sections = _sections(_row(), weight=60)
sections["detail"]["value"]["patient"] = {"id": 1502}
assert _build(sections, "综合分析") == ()
sections = _sections(_row())
sections["tracking"]["error"] = "接口失败"
assert _build(sections) == ()
def test_dto_snapshot_is_immutable_and_keeps_dates_gaps_and_duplicate_rows() -> None:
sections = _sections(
_row("2026-08-21", systolic_pressure=150),
_row("2026-08-18", systolic_pressure=140),
_row("2026-08-18", systolic_pressure=141),
_row("2026-08-19", systolic_pressure=None, diastolic_pressure=None),
)
before = deepcopy(sections)
data = _build(sections)[0]
assert sections == before
assert [item.value for item in data.items if item.series == "systolic_pressure"] == [
140,
141,
None,
150,
]
assert data.items[0].recorded_at == datetime(2026, 8, 18)
with pytest.raises(FrozenInstanceError):
data.items[0].value = 100 # type: ignore[misc]
sections["tracking"]["value"]["blood_records"][1]["systolic_pressure"] = 999
assert data.items[0].value == 140
@pytest.mark.parametrize(
"value", [True, "约 130", "120-140", "<140", float("nan"), float("inf"), 0, -1]
)
def test_ambiguous_pressure_readings_are_not_plotted(value: Any) -> None:
assert _build(_sections(_row(systolic_pressure=value, diastolic_pressure=None))) == ()
def test_no_dated_pressure_is_replaced_by_a_snapshot_only_when_detail_is_real() -> None:
sections = _sections(_row("不是日期"), systolic_pressure=130, diastolic_pressure=80)
data = _build(sections)[0]
assert data.kind == "metrics"
assert [item.value for item in data.items] == [130, 80]
assert all(item.recorded_at is None and item.reference is None for item in data.items)
def test_status_colors_come_only_from_source_and_units_are_separate() -> None:
data = _build(
_sections(
hba1c=12.5,
hba1c_status="normal",
cholesterol=5.2,
cholesterol_status="high",
microalbumin=12,
microalbumin_status="low",
weight=100,
bmi=35,
),
"综合分析",
)[0]
assert data.kind == "metrics"
metrics = {item.label: item for item in data.items}
assert metrics["糖化血红蛋白"].color == NORMAL_COLOR
assert metrics["总胆固醇"].color == ABNORMAL_COLOR
assert metrics["尿微量白蛋白"].color == ABNORMAL_COLOR
assert metrics["体重"].color == metrics["BMI"].color == UNKNOWN_COLOR
assert [
(metrics[key].unit) for key in ("糖化血红蛋白", "总胆固醇", "尿微量白蛋白", "体重", "BMI")
] == ["%", "mmol/L", "mg/L", "kg", "kg/m²"]
assert all(item.reference is None for item in data.items)
assert metrics["BMI"].value == 35
def test_source_units_override_defaults_without_conversion_or_shared_scale() -> None:
data = _build(
_sections(creatinine="1.1 mg/dL", creatinine_unit="mg/dL", weight="60 kg"), "肾功能与体重"
)[0]
assert [(item.value, item.unit) for item in data.items] == [(1.1, "mg/dL"), (60, "kg")]
assert all(item.reference is None for item in data.items)
def test_recorded_interval_is_distinct_from_exact_points_and_has_no_invented_date() -> None:
data = _build(
_sections(fasting_blood_sugar="6-7", postprandial_blood_sugar="8~9 mmol/L"), "血糖"
)[0]
assert data.kind == "metrics"
assert [item.interval for item in data.items] == [(6, 7), (8, 9)]
assert all(
item.value is None and item.recorded_at is None and item.reference is None
for item in data.items
)
assert data.items[1].value_text == "8~9 mmol/L"
def test_owned_patient_body_measurements_are_used_when_not_repeated_in_diagnosis() -> None:
sections = _sections()
sections["detail"]["value"]["patient"] = {"id": 1501, "weight": 68, "height": 168}
data = _build(sections, "体重与BMI")[0]
assert [(item.label, item.value, item.unit) for item in data.items] == [
("体重", 68, "kg"), ("身高", 168, "cm"),
]
assert all(item.color == UNKNOWN_COLOR and not item.status for item in data.items)
assert not any(item.label == "BMI" for item in data.items)
def test_metric_snapshot_uses_two_columns_on_wide_reports_and_localizes_status(application):
data = _build(_sections(hba1c=7.2, hba1c_status="high", weight=68, bmi=24), "综合分析")[0]
card = create_report_visual(data)
card.resize(760, card.sizeHint().height())
card.show()
application.processEvents()
wide_height = card.chart.height()
assert card.chart._columns == 2
assert "偏高" in card.chart.accessibleDescription()
assert card.table.item(0, 3).text() == "偏高"
assert "体重" in card.chart._point_tooltip(QPointF(card.chart.width() / 2 + 20, 10))
card.resize(320, card.height())
application.processEvents()
assert card.chart._columns == 1
assert card.chart.height() > wide_height
card.close()
card.deleteLater()
def test_reference_scale_requires_actual_stored_limits_and_keeps_zero() -> None:
data = _build(
_sections(
microalbumin=18,
microalbumin_reference_range={"low": 0, "high": 30, "unit": "mg/L"},
hba1c=7.2,
hba1c_reference_range="4-6 %",
weight=60,
),
"综合分析",
)[0]
assert [item.reference for item in data.items] == [(4, 6), (0, 30), None]
incompatible = _build(
_sections(
creatinine=100, creatinine_reference_range={"low": 0, "high": 2, "unit": "mg/dL"}
),
"肾功能",
)[0]
assert incompatible.items[0].reference is None
def test_foreign_nested_lab_values_are_not_adopted() -> None:
sections = _sections()
sections["detail"]["value"]["labs"] = {"diagnosis_id": 502, "hba1c": 7.2}
assert _build(sections, "糖化血红蛋白") == ()
@pytest.mark.parametrize(
("status", "color"),
[("high", ABNORMAL_COLOR), ("low", ABNORMAL_COLOR), ("warning", ABNORMAL_COLOR),
("异常", ABNORMAL_COLOR), ("normal", NORMAL_COLOR), ("正常", NORMAL_COLOR),
("unknown", UNKNOWN_COLOR), ("unclassified", UNKNOWN_COLOR), (None, UNKNOWN_COLOR),
(True, UNKNOWN_COLOR), ({"normal": True}, UNKNOWN_COLOR)],
)
def test_snapshot_flags_use_the_shared_palette_without_thresholds(status, color) -> None:
data = _build(_sections(hba1c=40, hba1c_status=status), "糖化血红蛋白")[0]
assert data.items[0].value == 40
assert data.items[0].color == color
assert "蓝色" not in data.source and "橙" not in data.source
assert "灰色未记录/未标注" in data.source
def test_pressure_points_raw_cells_and_tooltips_keep_only_recorded_states(application) -> None:
data = _build(_sections(
_row("2026-08-18", systolic_pressure=200, systolic_pressure_status="normal",
diastolic_pressure=60, diastolic_status="low"),
_row("2026-08-19", systolic_pressure=220, diastolic_pressure=None,
diastolic_pressure_status="normal"),
_row("2026-08-20", systolic_pressure=210, systolic_status="warning",
diastolic_pressure=70, diastolic_pressure_status="unknown"),
))[0]
assert [(item.status, item.color) for item in data.items] == [
("normal", NORMAL_COLOR), ("low", ABNORMAL_COLOR),
("", UNKNOWN_COLOR), ("normal", UNKNOWN_COLOR),
("warning", ABNORMAL_COLOR), ("unknown", UNKNOWN_COLOR),
]
card = create_report_visual(data)
card.resize(650, card.sizeHint().height())
card.show()
application.processEvents()
image = card.chart.grab().toImage()
for index, item in enumerate(data.items):
for column in (2, 3):
cell = card.table.item(index, column)
assert cell.foreground().color().name() == item.color.casefold()
assert "状态" in cell.toolTip()
if item.status:
assert item.status in cell.toolTip()
if item.value is not None:
point = card.chart._position(item)
assert image.pixelColor(point.toPoint()).name() == item.color.casefold()
assert item.status in card.chart._point_tooltip(point)
assert card.table.item(3, 2).text() == "—"
assert card.table.item(3, 3).text() == "未记录"
assert "舒张压 · — · 状态 未记录" in card.chart.accessibleDescription()
assert "收缩压实线圆点,舒张压虚线方点" in card.chart.accessibleDescription()
card.close()
def test_pressure_statuses_cannot_be_borrowed_from_root_or_foreign_rows() -> None:
sections = _sections(
_row(systolic_pressure=200, diastolic_pressure=110),
_row("2026-08-19", diagnosis_id=502, systolic_pressure_status="normal"),
)
sections["tracking"]["value"]["systolic_pressure_status"] = "normal"
items = _build(sections)[0].items
assert len(items) == 2
assert all(item.status == "" and item.color == UNKNOWN_COLOR for item in items)
def test_pressure_connection_is_gray_between_explicit_abnormal_points(application) -> None:
data = _build(_sections(
_row("2026-08-18", systolic_pressure=140, systolic_pressure_status="high",
diastolic_pressure=None),
_row("2026-08-20", systolic_pressure=140, systolic_pressure_status="high",
diastolic_pressure=None),
))[0]
card = create_report_visual(data)
card.resize(640, card.sizeHint().height())
card.show()
application.processEvents()
points = [card.chart._position(item) for item in data.items if item.value is not None]
midpoint = (points[0] + points[1]) / 2
image = card.chart.grab().toImage()
assert image.pixelColor(midpoint.toPoint()).name() == UNKNOWN_COLOR.casefold()
assert image.pixelColor(points[0].toPoint()).name() == ABNORMAL_COLOR.casefold()
card.close()
def test_reference_band_is_neutral_and_never_assigns_normal_status(application) -> None:
data = _build(_sections(hba1c=5, hba1c_reference_range="4-6 %"), "糖化血红蛋白")[0]
assert data.items[0].color == UNKNOWN_COLOR and data.items[0].status == ""
card = create_report_visual(data)
card.resize(400, card.sizeHint().height())
card.show()
application.processEvents()
image = card.chart.grab().toImage()
assert image.pixelColor(20, 54).name() == UNKNOWN_COLOR.casefold()
assert card.table.item(0, 2).foreground().color().name() == UNKNOWN_COLOR.casefold()
assert card.table.item(0, 3).text() == "未标注"
assert "状态 未标注" in card.table.item(0, 2).toolTip()
card.close()
def test_risks_are_exact_categorical_annotations_not_numeric_scores() -> None:
sections = _sections()
sections["analysis"] = {
"value": {
"patient_id": 1501,
"source_diagnosis_ids": [499, 501],
"risk_assessment": [
{"label": level, "level": level, "score": 99, "probability": 0.9}
for level in ("high", "medium", "low", "unknown")
]
+ [{"label": "未核验", "level": "severe"}, {"label": "无等级", "level": None}],
}
}
data = _build(sections, "风险评估")[0]
assert data.kind == "risks"
assert [item.status for item in data.items] == ["high", "medium", "low", "unknown"]
assert [item.color for item in data.items] == [
ABNORMAL_COLOR, ABNORMAL_COLOR, NORMAL_COLOR, UNKNOWN_COLOR,
]
assert all(
item.value is None and item.reference is None and "%" not in item.raw_value
for item in data.items
)
assert "定性风险标注" in data.source and "#499、#501" in data.source
@pytest.mark.parametrize(
"metadata",
[
{},
{"patient_id": 1502, "source_diagnosis_ids": [501]},
{"patient_id": 1501, "source_diagnosis_ids": [502]},
{"diagnosis_id": "501"},
{"diagnosis_id": 502},
],
)
def test_unowned_and_foreign_risk_reports_do_not_get_visuals(metadata: Any) -> None:
sections = _sections()
sections["analysis"] = {
"value": {**metadata, "risk_assessment": [{"label": "风险项", "level": "high"}]}
}
assert _build(sections, "风险") == ()
def test_nested_saved_report_uses_its_original_owner_and_levels() -> None:
sections = _sections()
sections["analysis"] = {
"value": {
"latest_by_model": {
"qwen": {
"patient_id": 1501,
"source_diagnosis_ids": [499, 501],
"diagnosis_id": 499,
"report": {"risk_assessment": [{"label": "血糖波动", "level": "medium"}]},
}
}
}
}
assert _build(sections, "风险")[0].items[0].status == "medium"
sections["analysis"]["value"]["latest_by_model"]["qwen"]["report"]["patient_id"] = 1502
assert _build(sections, "风险") == ()
def test_prescription_timeline_accepts_dto_and_preserves_dates_name_status() -> None:
sections = _sections()
sections["prescriptions"] = {
"value": [
Prescription(
diagnosis_id=501,
prescription_date="2026-08-19",
herbs=[{"name": "黄芪"}],
audit_status=1,
),
{
"diagnosis_id": 501,
"patient_id": 1501,
"prescription_date": "2026-08-18",
"prescription_name": "益气方",
"status_text": "已审核",
},
{
"diagnosis_id": 502,
"prescription_date": "2026-08-17",
"prescription_name": "外国诊单",
"status_text": "已审核",
},
{"diagnosis_id": 501, "prescription_name": "没有日期", "status_text": "已审核"},
]
}
data = _build(sections, "用药合理性")[0]
assert data.kind == "medications"
assert [(item.time_label, item.label, item.status) for item in data.items] == [
("2026-08-18", "益气方", "已审核"),
("2026-08-19", "黄芪", "已通过"),
]
assert all(item.value is None for item in data.items)
def test_dto_coercion_does_not_hide_invalid_raw_prescription_ownership() -> None:
sections = _sections()
sections["prescriptions"] = {
"value": [
Prescription.from_dict(
{
"diagnosis_id": "501",
"prescription_date": "2026-08-18",
"prescription_name": "益气方",
"status_text": "已审核",
}
)
]
}
assert _build(sections, "用药") == ()
def test_unitless_bmi_state_remains_a_measurement_instead_of_a_risk_label(application) -> None:
data = _build(_sections(bmi=26.1, bmi_status="high"), "体重")[0]
data = replace(data, items=tuple(replace(item, unit="") for item in data.items))
card = ReportVisualCard(data)
try:
assert card.table.item(0, 3).text() == "偏高"
assert card.table.item(0, 3).foreground().color().name() == "#bd4b58"
finally:
card.deleteLater()
def test_exercise_uses_actual_duration_minutes_with_no_target_bars() -> None:
sections = _sections()
sections["tracking"]["value"]["exercise_records"] = [
_row("2026-08-18", duration=35),
_row("2026-08-19", duration="约40"),
_row("2026-08-20", duration=40, patient_id=1502),
]
data = _build(sections, "建议运动150分钟")[0]
assert data.kind == "exercise"
assert [item.value for item in data.items] == [35, None]
assert data.items[0].unit == "分钟"
def test_charts_render_at_320px_with_exact_hover_and_raw_data(application: QApplication) -> None:
sections = _sections(
_row("2026-08-18"), _row("2026-08-19"), _row("2026-08-21"), hba1c=7.2, hba1c_status="high"
)
sections["analysis"] = {
"value": {
"diagnosis_id": 501,
"risk_assessment": [{"label": "血压波动风险", "level": "medium"}],
}
}
sections["prescriptions"] = {
"value": [
{
"diagnosis_id": 501,
"prescription_date": "2026-08-19",
"prescription_name": "益气方",
"status_text": "已审核",
}
]
}
sections["tracking"]["value"]["exercise_records"] = [_row(duration=35)]
data = _build(sections, "综合分析")
assert {_kind(item) for item in data} == {
"glucose",
"blood_pressure",
"metrics",
"risks",
"medications",
"exercise",
}
for item in data:
card = create_report_visual(item)
card.resize(320, card.sizeHint().height())
card.show()
application.processEvents()
try:
assert card.width() == 320
assert card.minimumSizeHint().width() <= 320
assert not card.grab().isNull()
assert not any("暂无可绘制" in label.text() for label in card.findChildren(QLabel))
if isinstance(card, GlucoseReportCard):
continue
assert isinstance(card, ReportVisualCard)
assert (
card.objectName() == "AiConsultReportVisual"
and card.property("visualKind") == item.kind
)
QTest.mouseClick(card.toggle_button, Qt.MouseButton.LeftButton)
application.processEvents()
assert card.table.isVisible() and card.table.rowCount() == len(item.items)
assert card.table.item(0, 1).text() == item.items[0].label
position = card.chart._position(item.items[0]) if card.chart._trend else QPointF(20, 10)
assert item.items[0].label in card.chart._point_tooltip(position)
if item.kind == "blood_pressure":
first, middle, last = [
datum for datum in item.items if datum.series == "systolic_pressure"
]
positions = [card.chart._position(datum).x() for datum in (first, middle, last)]
assert (positions[1] - positions[0]) / (
positions[2] - positions[0]
) == pytest.approx(1 / 3)
QTest.mouseMove(card.chart, position.toPoint())
application.processEvents()
assert "146 mmHg" in QToolTip.text()
finally:
card.close()
card.deleteLater()
+5 -7
View File
@@ -1413,7 +1413,7 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
if widget.objectName() == "AiConsultBubbleText"
]
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
assert not any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
dialog.close()
@@ -1443,11 +1443,11 @@ def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
return dialog
def test_full_context_notice_is_shown_once_per_conversation(
def test_full_context_notice_is_not_displayed_in_conversations(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
# 技术性的上下文说明不应占用对话区,包括首次提问和切换患者后。
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
@@ -1458,13 +1458,12 @@ def test_full_context_notice_is_shown_once_per_conversation(
dialog._cancel_stream()
dialog._ask(question)
counts.append(sum(1 for text in _bubble_texts(dialog) if notice in text))
assert counts == [1, 1, 1]
assert counts == [0, 0, 0]
# 换患者视为新会话,需要重新提示一次。
dialog.open_for(diagnosis_id=502, patient_id=302)
application.processEvents()
dialog._ask("新患者的问题")
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 1
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 0
dialog.close()
@@ -1710,7 +1709,6 @@ def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
)
dialog = AiConsultDialog(RemoteDoctorRepository(client))
dialog.diagnosis_id = 501
dialog._context_notice_shown = True
delivered_on = []
original_event = dialog._stream_event
+2
View File
@@ -11,6 +11,7 @@ from typing import Any
import httpx
import pytest
from doctor_workstation import __version__
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiError,
@@ -47,6 +48,7 @@ def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
)
assert request.headers["token"] == "secret-token"
assert request.headers["version"] == "1.9.4"
assert request.headers["user-agent"] == f"ZytDoctorWorkstation/{__version__}"
assert ApiClient.normalise_base_url("https://example.test/adminapi") == (
"https://example.test/adminapi/"
)
+206
View File
@@ -0,0 +1,206 @@
"""Archived IM events must be readable in the diagnosis drawer and AI history."""
from __future__ import annotations
import json
import os
from copy import deepcopy
from datetime import datetime
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent, Qt
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
from doctor_workstation.ui.chat_items import format_chat_time, parse_chat_item
from doctor_workstation.ui.diagnosis_drawer import ChatPanel, _RemoteImageButton
STAMP = 1792454400
def _event(payload, **changes):
return {
"msg_type": "custom",
"content": json.dumps(payload, ensure_ascii=False),
"create_time": STAMP,
**changes,
}
def _rtc(command, *, seconds=None, call_type=2, encoded=True):
body = {
"version": 4,
"call_type": call_type,
"businessID": "rtc_call",
"data": {"cmd": command, "inviter": "doctor_synthetic"},
}
if seconds is not None:
body["call_end"] = seconds
return {
"businessID": 1,
"data": json.dumps(body) if encoded else body,
"actionType": 1,
"inviteID": "synthetic-private-invite",
}
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.fixture
def panel(application):
widget = ChatPanel()
widget.resize(1100, 850)
widget.show()
yield widget
widget.close()
widget.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
@pytest.mark.parametrize(
"business,title",
[
("patient_opened_chat", "患者进入在线问诊"),
("patient_left_chat", "患者离开在线问诊"),
("doctor_entered_consult_room", "医生进入在线诊室"),
("consultation_complete", "本次问诊已完成"),
],
)
def test_drawer_recognizes_custom_events_in_content_and_formats_archive_time(
panel, business, title
):
row = _event(
{"businessID": business, "patientName": "示例患者", "patientId": "synthetic-private-id"}
)
before = deepcopy(row)
panel.set_messages([row])
assert panel.findChild(QLabel, "DiagnosisChatEventTitle").text() == title
assert panel.findChild(QLabel, "DiagnosisChatMeta").text() == datetime.fromtimestamp(
STAMP
).strftime("%Y-%m-%d %H:%M")
assert not panel.findChildren(QFrame, "DiagnosisChatBubble")
visible_text = " ".join(label.text() for label in panel.findChildren(QLabel))
assert "businessID" not in visible_text and "synthetic-private-id" not in visible_text
assert row == before
@pytest.mark.parametrize("encoded", [False, True])
@pytest.mark.parametrize(
"command,seconds,title,detail",
[
("videoCall", None, "发起视频问诊", "医生已向患者发起视频通话"),
("hangup", "116", "视频问诊已结束", "通话 1 分 56 秒"),
("hangup", "154", "视频问诊已结束", "通话 2 分 34 秒"),
],
)
def test_nested_rtc_signals_prefer_the_command_and_show_duration(
panel, encoded, command, seconds, title, detail
):
panel.set_messages([_event(_rtc(command, seconds=seconds, encoded=encoded))])
assert panel.findChild(QLabel, "DiagnosisChatEventTitle").text() == title
assert panel.findChild(QLabel, "DiagnosisChatEventDetail").text() == detail
visible_text = " ".join(label.text() for label in panel.findChildren(QLabel))
assert all(
token not in visible_text
for token in ("rtc_call", "inviteID", "call_end", "synthetic-private-invite")
)
@pytest.mark.parametrize("value", [STAMP, str(STAMP), STAMP * 1000, str(STAMP * 1000)])
def test_seconds_and_milliseconds_have_the_same_display_time(value):
expected = datetime.fromtimestamp(STAMP)
assert format_chat_time(value) == expected.strftime("%H:%M")
assert format_chat_time(value, include_date=True) == expected.strftime("%Y-%m-%d %H:%M")
def test_iso_and_payload_only_times_are_formatted_for_both_views():
assert format_chat_time("2026-10-20T08:30:45Z") == "08:30"
assert format_chat_time("2026-10-20T08:30:45Z", include_date=True) == "2026-10-20 08:30"
row = _event({"businessID": "patient_opened_chat", "time": STAMP * 1000}, create_time=None)
assert parse_chat_item(row, include_date=True).time_text == datetime.fromtimestamp(
STAMP
).strftime("%Y-%m-%d %H:%M")
assert parse_chat_item(
{"text": "普通消息", "timestamp": STAMP}
).time_text == datetime.fromtimestamp(STAMP).strftime("%H:%M")
@pytest.mark.parametrize("value", [None, "", 0, -1, float("nan"), float("inf"), True])
def test_missing_or_invalid_numeric_times_do_not_render_as_epoch_or_raw_numbers(value):
assert format_chat_time(value, include_date=True) == ""
def test_only_typing_signals_show_the_empty_state(panel, application):
panel.set_messages([_event({"businessID": "user_typing_status", "typing": True})])
application.processEvents()
assert panel.empty.isVisible()
assert not panel.findChildren(QFrame, "DiagnosisChatBubble")
assert not panel.findChildren(QFrame, "DiagnosisChatEvent")
def test_unknown_technical_payload_uses_readable_fallback_without_internal_ids(panel):
panel.set_messages([_event({"business_id": "new_private_signal", "inviteID": "private-token"})])
assert panel.findChild(QLabel, "DiagnosisChatEventTitle").text() == "系统状态已同步"
assert "private-token" not in " ".join(label.text() for label in panel.findChildren(QLabel))
def test_ordinary_chat_keeps_exact_multiline_content_and_sender_side(panel):
text = " 我已确认以下信息:\n1、本人确认已在线下确诊;\n2、本人已阅读告知书。\n<b>保留原文</b> "
panel.set_messages(
[
{"content": text, "is_from_doctor": "0", "time": STAMP},
{"text": "请补充资料", "from_account": "doctor_synthetic", "time": STAMP},
]
)
bubbles = panel.findChildren(QFrame, "DiagnosisChatBubble")
assert [bubble.property("side") for bubble in bubbles] == ["patient", "doctor"]
body = bubbles[0].findChild(QLabel, "DiagnosisChatText")
assert body.text() == text and body.textFormat() == Qt.TextFormat.PlainText
def test_mixed_events_and_attachments_preserve_image_preview_and_file_actions(panel, monkeypatch):
monkeypatch.setattr(_RemoteImageButton, "load_url", lambda *_args, **_kwargs: None)
image_url = "https://example.invalid/synthetic.jpg"
file_url = "https://example.invalid/synthetic.pdf"
opened = []
panel.open_attachment_requested.connect(opened.append)
panel.set_messages(
[
_event({"businessID": "doctor_entered_consult_room"}),
{"msg_type": "image", "image_url": image_url, "time": STAMP},
{"msg_type": "file", "file_url": file_url, "file_name": "示例报告.pdf", "time": STAMP},
]
)
panel.findChild(_RemoteImageButton, "DiagnosisChatImage").click()
next(
button for button in panel.findChildren(QPushButton) if button.text() == "示例报告.pdf"
).click()
assert opened == [image_url, file_url]
assert len(panel.findChildren(QFrame, "DiagnosisChatEvent")) == 1
def test_nested_duration_and_audio_command_are_not_misclassified():
ended = parse_chat_item(
_event(
{"businessID": "rtc_call", "call_type": 1, "data": {"cmd": "hangup", "call_end": 61}}
)
)
assert ended.title == "语音问诊已结束" and ended.detail == "通话 1 分 1 秒"
started = parse_chat_item(_event({"businessID": "rtc_call", "data": {"cmd": "audioCall"}}))
assert started.title == "发起语音问诊"
def test_text_field_has_the_same_priority_in_both_chat_views():
item = parse_chat_item({"text": "本条正文", "content": "旧字段"})
assert item.text == "本条正文"
def test_invalid_json_is_preserved_without_interrupting_other_messages(panel):
broken = '{"unfinished":'
panel.set_messages([{"content": broken}, _event({"businessID": "patient_opened_chat"})])
assert panel.findChild(QLabel, "DiagnosisChatText").text() == broken
assert panel.findChild(QLabel, "DiagnosisChatEventTitle").text() == "患者进入在线问诊"
+26
View File
@@ -619,6 +619,32 @@ def _select_tab(dialog: DiagnosisDialog, key: str) -> None:
QApplication.processEvents()
def test_chat_tab_recognizes_archived_system_events_in_the_actual_drawer(application: QApplication) -> None:
class ChatRepository(VisualRepository):
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True) -> dict[str, Any]:
assert diagnosis_id == 501 and only_archived
return {"lists": [
{"content": '{"businessID":"patient_opened_chat","patientName":"示例患者"}',
"create_time": 1792454400},
{"text": "我已确认问诊信息。", "create_time": 1792454401},
{"text": '{"businessID":"doctor_entered_consult_room"}',
"create_time": 1792454402},
]}
dialog = _open_dialog(application, (1440, 900), mode="edit", repository=ChatRepository())
try:
_select_tab(dialog, "chat")
panel = dialog._chat_panels[1]
assert panel.isVisibleTo(dialog)
assert [label.text() for label in panel.findChildren(QLabel, "DiagnosisChatEventTitle")] == [
"患者进入在线问诊", "医生进入在线诊室",
]
assert panel.findChild(QLabel, "DiagnosisChatText").text() == "我已确认问诊信息。"
assert all("17924544" not in label.text() for label in panel.findChildren(QLabel, "DiagnosisChatMeta"))
finally:
dialog.close()
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
def test_readonly_is_an_independent_vertical_page_flow(
application: QApplication,
+28 -1
View File
@@ -794,6 +794,33 @@ def test_revoked_agreement_cache_discards_flat_comparison_metadata() -> None:
assert value["models"]["qwen"]["reason"] == sentinel
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
def test_unlisted_account_hides_ai_analysis_column_and_keeps_reports(
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, flag: Any,
) -> None:
repository = Repository()
repository.agreement_visible = flag
opened = []
monkeypatch.setattr(page_module, "present_issued_prescription_ai", lambda *_args, **kwargs: opened.append(kwargs))
page = page_module.PrescriptionsPage(repository, ["*"], {"root": 1})
try:
page.resize(1366, 800)
page.show()
application.processEvents()
page.table.selectRow(0)
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
# Later status refreshes must not re-expose the analysis column.
page._set_ai_columns(True)
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
assert page.ai_report_button.isVisible() and page.ai_report_button.isEnabled()
assert any(button.accessibleName() == "AI 报告"
for button in page.table.cellWidget(0, 2).findChildren(QPushButton))
page.ai_report_button.click()
assert opened == [{"prescription_id": 801}]
finally:
page.close()
def test_list_capability_failure_revocation_and_late_status_cannot_restore_scores(
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -810,7 +837,7 @@ def test_list_capability_failure_revocation_and_late_status_cannot_restore_score
page._load_ai_capabilities()
page._ai_statuses_ready(old_statuses, [801, 802], old_token, page._generation)
page._ai_capabilities_ready({"agreement_visible": True}, old_capability_token, page._generation)
assert page.table.isColumnHidden(12) and not page.table.isColumnHidden(11)
assert page.table.isColumnHidden(12) and page.table.isColumnHidden(11)
assert page.ai_statistics_button.isHidden()
assert page.ai_report_button.isVisible()
assert page._ai_statuses
@@ -11,7 +11,7 @@ from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt, QTimer
from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
@@ -234,3 +234,60 @@ def test_row_action_permissions_and_foreign_public_management_are_preserved(
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
else:
assert page.edit_button.isHidden() and page.delete_button.isHidden()
def test_table_fills_viewport_after_window_and_scrollbar_changes(page_factory, application):
page, repository = page_factory()
table = page.table
page.resize(1024, 768)
settle(application)
minimums = [table.columnWidth(column) for column in range(table.columnCount())]
source = deepcopy(repository.rows[0])
for width, count in ((2048, 2), (2048, 70), (1024, 70), (1920, 0),
(1920, 2), (1366, 2), (2048, 2)):
page.resize(width, 768)
table.set_rows([{**deepcopy(source), "id": 10000 + index} for index in range(count)])
settle(application)
viewport = table.viewport()
if count:
assert table.fontMetrics().horizontalAdvance(table.item(0, 0).text()) + 24 <= table.columnWidth(0)
assert table.horizontalHeader().length() == max(sum(minimums), viewport.width())
assert all(table.columnWidth(column) >= minimum for column, minimum in enumerate(minimums))
if viewport.width() >= sum(minimums):
assert table.horizontalScrollBar().maximum() == 0
assert all(table.columnWidth(column) > minimums[column] for column in (1, 4, 5))
if count:
last_cell = table.visualItemRect(table.item(0, 9))
index = table.indexAt(QPoint(viewport.width() - 3, last_cell.center().y()))
assert (index.row(), index.column()) == (0, 9)
else:
assert table.horizontalScrollBar().maximum() > 0
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
settle(application)
assert table.visualItemRect(table.item(0, 9)).right() < viewport.width()
def test_manual_column_width_survives_resize_and_refresh(page_factory, application):
page, _repository = page_factory()
table = page.table
page.resize(2048, 900)
settle(application)
table.sortItems(0, Qt.SortOrder.DescendingOrder)
table.selectRow(1)
selected_id = record_id(table.current_data())
action_host = table.cellWidget(1, 9)
requested_width = table.columnWidth(1) + 60
table.setColumnWidth(1, requested_width)
settle(application)
for width in (1024, 1920, 1366, 2048):
page.resize(width, 900)
settle(application)
assert table.columnWidth(1) == requested_width
assert record_id(table.current_data()) == selected_id
assert table.cellWidget(1, 9) is action_host
page.refresh()
settle(application)
assert table.columnWidth(1) == requested_width
assert record_id(table.current_data()) == selected_id
assert table.horizontalHeader().sortIndicatorOrder() == Qt.SortOrder.DescendingOrder
assert table.horizontalHeader().length() == table.viewport().width()
+1 -1
View File
@@ -55,7 +55,7 @@ def test_typography_preserves_regular_body_and_distinct_values(page):
body = page.case_labels["present"]
assert body.font().pixelSize() == 14
assert body.font().weight() == 400
assert page.patient_name_label.font().pixelSize() == 22
assert page.patient_name_label.font().pixelSize() == 20
assert page.patient_name_label.font().weight() == 600
assert page.vital_labels["height"].font().pixelSize() == 18
+8 -8
View File
@@ -261,15 +261,15 @@ def test_approved_shell_geometry_and_function_entries_restore(
assert window.centralWidget().layout().contentsMargins().isNull()
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
assert window.search_host.size() == QSize(384, 44)
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
assert window.assistant_card.size() == QSize(168, 171)
assert window.assistant_button.height() == 46
assert window.brand_name.font().pixelSize() == 18
assert window.brand_subtitle.font().pixelSize() == 13
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 776)
assert window.assistant_card.size() == QSize(168, 132)
assert window.assistant_button.height() == 36
assert window.brand_name.font().pixelSize() == 16
assert window.brand_subtitle.font().pixelSize() == 12
for index, button in enumerate(window.nav_buttons.values()):
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
assert button.height() == 52
assert button.font().pixelSize() == 15
assert button.mapTo(window, QPoint()).y() == 99 + 48 * index
assert button.height() == 44
assert button.font().pixelSize() == 14
assert button.iconSize() == QSize(20, 20)
assert not window.fold_button.isVisible()
assert not window.ai_top_button.isVisible()
+25 -19
View File
@@ -21,6 +21,7 @@ from PySide6.QtCore import (
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QLabel,
@@ -378,7 +379,8 @@ def test_queue_condition_summary_never_exposes_raw_dict(
)
fallback_summary = fallback.findChild(QLabel, "ReceptionQueueSubline")
assert fallback_summary is not None
assert fallback_summary.text() == "暂无病情摘要"
assert fallback_summary.text() == ""
assert fallback_summary.toolTip() == ""
assert "{" not in fallback_summary.text()
row.close()
@@ -1045,14 +1047,17 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
detail["patient"],
diagnosis,
)
application.processEvents()
for _ in range(12):
application.processEvents()
QTest.qWait(5)
assert page.diagnosis_card.objectName() == "ReceptionMedicationCaseCard"
assert page.diagnosis_card.accessibleName() == "完整病例"
assert page.medication_scroll.objectName() == "ReceptionMedicationScroll"
assert page.medication_scroll.accessibleName() == "用药记录与完整病例滚动区域"
assert page.medication_scroll.horizontalScrollBar().maximum() == 0
assert page.medication_scroll.verticalScrollBar().maximum() > 0
case_scrolls = (page.clinical_scroll, page.medication_scroll)
assert any(scroll.verticalScrollBar().maximum() > 0 for scroll in case_scrolls)
assert page.diagnosis_text.isHidden()
assert page.diagnosis_text.textFormat() == Qt.TextFormat.PlainText
@@ -1095,14 +1100,16 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
for _ in range(5):
page.medication_scroll.verticalScrollBar().setValue(
page.medication_scroll.verticalScrollBar().maximum()
)
for scroll in case_scrolls:
scroll.verticalScrollBar().setValue(scroll.verticalScrollBar().maximum())
application.processEvents()
assert (
page.medication_scroll.verticalScrollBar().value()
== page.medication_scroll.verticalScrollBar().maximum()
)
assert all(scroll.verticalScrollBar().value() == scroll.verticalScrollBar().maximum()
for scroll in case_scrolls)
final_note = page.case_section_frames["notes"].findChild(QLabel, "ReceptionCaseFieldValue")
assert final_note is not None
assert "【完整病例末尾】" in final_note.text()
viewport = page.clinical_scroll.viewport()
assert viewport.rect().contains(final_note.mapTo(viewport, final_note.rect().bottomLeft()))
page.close()
application.processEvents()
@@ -1446,12 +1453,14 @@ def test_phone_permission_and_ungated_notify_video_actions(
masked_page = ReceptionPage(Repository(), PermissionSet([]))
masked_page._select_record(detail["appointment"])
assert masked_page.patient_labels["phone"].text() == "138****8000"
assert masked_page.patient_summary_labels["phone"].text() == "138****8000"
assert not masked_page.notify_button.isHidden()
assert not masked_page.video_button.isHidden()
plain_page = ReceptionPage(Repository(), PermissionSet(["tcm.diagnosis/phonePlain"]))
plain_page._select_record(detail["appointment"])
assert plain_page.patient_labels["phone"].text() == "13800138000"
assert plain_page.patient_summary_labels["phone"].text() == "13800138000"
assert plain_page.appointment_labels["doctor"].text() == "张医生"
assert plain_page.appointment_labels["assistant"].text() == "李医助"
assert plain_page.patient_labels["region"].text() == "浙江省杭州市"
@@ -1524,8 +1533,8 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
for button in buttons:
assert hero.rect().contains(button.geometry())
assert button.width() >= button.sizeHint().width()
right = button.mapTo(page.detail_scroll.viewport(), QPoint(button.width(), 0)).x()
assert right <= page.detail_scroll.viewport().width()
right = button.mapTo(page.detail_stack, QPoint(button.width(), 0)).x()
assert right <= page.detail_stack.width()
for previous, following in zip(buttons, buttons[1:], strict=False):
assert previous.geometry().right() < following.geometry().left()
finally:
@@ -1700,10 +1709,7 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
assert [label.property("riskLevel") for label in chips] == ["high", "medium", "low"]
assert "千问" in page.ai_analysis_title.toolTip()
assert [button.text() for button in page.ai_prompt_buttons] == [
"基于当前病史,下一步检查建议?",
"该患者的用药调整建议?",
"糖尿病教育要点有哪些?",
"并发症筛查建议?",
"检查建议", "用药核对", "健康教育", "并发症筛查",
]
assert page.ai_send_button.text() == ""
assert page.ai_send_button.accessibleName() == "发送 AI 问诊问题"
@@ -1734,9 +1740,9 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
assert page.ai_analysis_card.minimumHeight() == 0
assert page.ai_analysis_card.maximumHeight() > 520
assert page.ai_analysis_card.findChildren(QScrollArea) == []
assert left.height() == right.height()
assert right.left() - left.right() - 1 == 10
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
assert left.left() == right.left()
assert left.width() == right.width()
assert right.top() - left.bottom() - 1 == 10
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48
@@ -0,0 +1,324 @@
"""Reachability and data preservation in the real reception shell."""
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, QPoint, QRect, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import QueueRow, ReceptionPage
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _settle(application: QApplication) -> None:
for _ in range(12):
application.processEvents()
QTest.qWait(5)
def _mapped_rect(widget, ancestor) -> QRect:
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
class _LocalRepository:
def __init__(self) -> None:
self.calls: list[str] = []
self.detail = {
"appointment": {
"id": 51, "patient_id": 151, "diagnosis_id": 251,
"patient_name": "本地测试患者", "status": 1,
"doctor_name": "测试医生", "assistant_name": "测试医助",
"appointment_time": "09:30", "appointment_type": "video",
},
"patient": {
"id": 151, "name": "本地测试患者", "gender": 2,
"age": 46, "height": 165, "weight": 60, "phone": "13800138000",
},
"diagnosis": {
"id": 251, "patient_id": 151, "chief_complaint": "反复口渴",
"present_illness": "近期口干,复诊时核对记录。",
"current_medications": "二甲双胍早晚服用,剂量待核对。",
"disease_course_text": "患病十年",
"clinical_diagnosis": "2 型糖尿病", "hba1c": 7.2,
},
}
def list_appointments(self, **_query: Any) -> dict[str, Any]:
self.calls.append("queue")
return {"lists": [deepcopy(self.detail["appointment"])], "count": 1}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 51
self.calls.append("detail")
return deepcopy(self.detail)
def get_diagnosis_ai_analysis(self, diagnosis_id: int, model: str) -> dict[str, Any]:
assert diagnosis_id == 251
self.calls.append(model)
return {
"diagnosis_advice": "结合当前病史复核血糖控制情况。",
"risk_assessment": [{"label": "血糖波动", "level": "medium"}],
"treatment_advice": "核对现有用药及近期检查。", "model_key": model,
}
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def reject_network(*_args, **_kwargs):
pytest.fail("Reception workspace tests must remain local")
def immediate(function, *args, on_success=None, on_error=None, on_finished=None,
pool=None, priority=0, **kwargs):
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(reception_module, "run_async", immediate)
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
item = NavigationItem("reception", "接诊台", "", ReceptionPage,
("doctor.appointment/lists",))
monkeypatch.setattr(shell_module, "_resolve_navigation",
lambda *_args, **_kwargs: [(item, item.title)])
opened = []
def create(width=1920, height=1080):
repository = _LocalRepository()
shell = ShellWindow(repository, {"user": {"id": 7, "name": "测试医生"}},
permissions={
"doctor.appointment/lists", "doctor.appointment/complete",
"doctor.appointment/addDoctorNote", "tcm.diagnosis/edit",
"tcm.diagnosis/aiAnalysis", "tcm.diagnosis/aiAssistant",
})
opened.append(shell)
shell.resize(width, height)
shell.show()
_settle(application)
page = shell.pages["reception"]
page.poll_timer.stop()
shell.chat_notifications.stop()
assert page._selected_appointment_id == 51
assert page.detail_stack.currentIndex() == 1
return shell, page, repository
yield create
for shell in opened:
for timer in shell.findChildren(QTimer):
timer.stop()
shell.close()
shell.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()
@pytest.mark.parametrize("size", [(1366, 768), (1920, 1080)])
def test_real_shell_prioritizes_patient_facts_while_long_history_scrolls(
application, shell_factory, size,
):
shell, page, repository = shell_factory(*size)
assert (shell.width(), shell.height()) == size
assert not page.workbench.mode_tabs.isVisible()
hero = page.findChild(QFrame, "ReceptionHero")
hero_before = _mapped_rect(hero, page)
actions = (page.notify_button, page.history_button, page.video_button,
page.more_button, page.complete_button)
actions_before = [_mapped_rect(button, page) for button in actions]
for button in actions:
assert button.isVisibleTo(page)
assert hero.rect().contains(_mapped_rect(button, hero))
assert button.width() >= button.minimumSizeHint().width()
insights_viewport = page.insights_scroll.viewport()
summary = page.patient_summary_card
vitals = page.findChild(QFrame, "ReceptionVitalsCard")
labs = page.findChild(QFrame, "ReceptionLabsCard")
assert insights_viewport.rect().contains(_mapped_rect(summary, insights_viewport))
assert insights_viewport.rect().contains(_mapped_rect(vitals, insights_viewport))
assert _mapped_rect(summary, insights_viewport).top() == 0
assert summary.mapTo(page, QPoint()).y() < vitals.mapTo(page, QPoint()).y()
assert vitals.mapTo(page, QPoint()).y() < labs.mapTo(page, QPoint()).y()
assert labs.mapTo(page, QPoint()).y() < page.ai_analysis_card.mapTo(page, QPoint()).y()
assert {key: label.text() for key, label in page.patient_summary_labels.items()} == {
"gender_age": "女 · 46岁", "phone": "138****8000",
"course": "患病十年", "diagnosis": "2 型糖尿病",
}
assistant_title = page.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle")
insight_scroll_value = page.insights_scroll.verticalScrollBar().value()
fact_positions = [_mapped_rect(card, page) for card in (summary, vitals, labs)]
requests = list(repository.calls)
history = "完整病史:口干持续,携带记录复诊,核对既往用药。\n" * 40
page.case_labels["present"].setText(history)
_settle(application)
assert page.case_labels["present"].text() == history
assert page.case_labels["present"].height() >= page.case_labels["present"].heightForWidth(
page.case_labels["present"].width()
)
scrollbar = page.clinical_scroll.verticalScrollBar()
assert scrollbar.maximum() > 0
scrollbar.setValue(scrollbar.maximum())
_settle(application)
assert scrollbar.value() == scrollbar.maximum()
assert _mapped_rect(hero, page) == hero_before
assert [_mapped_rect(button, page) for button in actions] == actions_before
assert [_mapped_rect(card, page) for card in (summary, vitals, labs)] == fact_positions
assert page.insights_scroll.verticalScrollBar().value() == insight_scroll_value
for title in (page.ai_analysis_title, assistant_title):
page.insights_scroll.ensureWidgetVisible(title)
_settle(application)
assert insights_viewport.rect().contains(_mapped_rect(title, insights_viewport))
assert repository.calls == requests
def test_narrow_workspace_reuses_scrolls_and_preserves_drafts_tabs_and_patient(
application, shell_factory,
):
shell, page, repository = shell_factory()
clinical, insights = page.clinical_scroll, page.insights_scroll
page.detail_tabs.setCurrentIndex(4)
page.note_edit.setPlainText("未保存的本地备注")
page._pending_tongue_images = ["draft-tongue.png"]
page._pending_report_files = ["draft-report.pdf"]
requests = list(repository.calls)
for width in (1024, 1920, 1024, 1366):
shell.resize(width, 768)
_settle(application)
assert page.clinical_scroll is clinical
assert page.insights_scroll is insights
assert page.detail_tabs.currentIndex() == page.clinical_pages.currentIndex() == 4
assert page._selected_appointment_id == 51
assert page.note_edit.toPlainText() == "未保存的本地备注"
assert page._pending_tongue_images == ["draft-tongue.png"]
assert page._pending_report_files == ["draft-report.pdf"]
if width == 1024:
assert page.workbench.mode_tabs.isVisible()
for index, scroll in ((1, insights), (0, clinical)):
page.workbench.mode_tabs.setCurrentIndex(index)
_settle(application)
assert scroll.isVisibleTo(page)
assert page.workbench._narrow_stack.currentWidget() is scroll
else:
assert not page.workbench.mode_tabs.isVisible()
assert clinical.isVisibleTo(page) and insights.isVisibleTo(page)
assert repository.calls == requests
def test_missing_labs_expand_and_collapse_without_losing_recorded_values(
application, shell_factory,
):
_shell, page, _repository = shell_factory()
before = {key: label.text() for key, label in page.lab_labels.items()}
assert before["hba1c"] == "7.2 %"
assert not page.lab_cells["hba1c"].isHidden()
assert all(cell.isHidden() for key, cell in page.lab_cells.items() if key != "hba1c")
assert page.lab_missing_button.text() == "显示 7 项未记录"
page.insights_scroll.ensureWidgetVisible(page.lab_missing_button)
_settle(application)
QTest.mouseClick(page.lab_missing_button, Qt.MouseButton.LeftButton)
_settle(application)
assert all(not cell.isHidden() for cell in page.lab_cells.values())
assert page.lab_missing_button.text() == "收起未记录项"
page.lab_missing_button.click()
_settle(application)
assert {key: label.text() for key, label in page.lab_labels.items()} == before
assert all(cell.isHidden() for key, cell in page.lab_cells.items() if key != "hba1c")
def test_free_text_glucose_keeps_source_without_invented_units_or_status(
application, shell_factory,
):
_shell, page, repository = shell_factory()
description = "近期空腹7至9之间,餐后约12,尚未携带原始记录"
diagnosis = {**repository.detail["diagnosis"], "fasting_blood_sugar": description}
page._render_case(repository.detail["appointment"], repository.detail["patient"], diagnosis)
_settle(application)
assert page.lab_labels["fasting"].text() == description
assert page.lab_labels["fasting"].textFormat() == Qt.TextFormat.PlainText
assert page.lab_cells["fasting"].property("description") is True
assert not page.lab_cells["fasting"].isHidden()
assert page.lab_state_labels["fasting"].isHidden()
assert page.case_metric_labels["fasting_glucose"].text() == description
assert f"空腹血糖:{description}\n" in page.diagnosis_text.text()
assert f"{description} mmol/L" not in page.health_summary_label.text()
row = QueueRow({**repository.detail["appointment"], "diagnosis": diagnosis})
try:
metric = row.findChild(QLabel, "ReceptionQueueMetric")
assert metric.text() == "血糖有描述"
assert metric.toolTip() == description
assert metric.property("tone") == "description"
assert row.glucose_drop.isHidden()
finally:
row.deleteLater()
def test_compact_ai_prompts_keep_full_questions_and_callbacks(shell_factory, monkeypatch):
_shell, page, _repository = shell_factory()
sent = []
monkeypatch.setattr(page, "_open_ai_report_for_prompt", sent.append)
questions = [
"基于当前病史,下一步检查建议?", "该患者的用药调整建议?",
"糖尿病教育要点有哪些?", "并发症筛查建议?",
]
labels = ["检查建议", "用药核对", "健康教育", "并发症筛查"]
assert len(page.ai_prompt_buttons) == len(questions)
for button, label, question in zip(page.ai_prompt_buttons, labels, questions, strict=True):
assert button.text() == label
assert button.toolTip() == button.accessibleName() == question
assert button.isEnabled()
button.click()
assert sent == questions
def test_patient_facts_stay_first_while_ai_is_loading_or_unavailable(application, shell_factory):
_shell, page, repository = shell_factory()
before = {key: label.text() for key, label in page.patient_summary_labels.items()}
requests = list(repository.calls)
for state in ("loading", "error", "permission"):
page._set_ai_analysis_state(state)
_settle(application)
assert {key: label.text() for key, label in page.patient_summary_labels.items()} == before
assert page.patient_summary_card.mapTo(page.insights_scroll.widget(), QPoint()).y() == 0
assert repository.calls == requests
def test_patient_change_returns_to_facts_and_clears_previous_summary(application, shell_factory, monkeypatch):
_shell, page, _repository = shell_factory()
page.insights_scroll.verticalScrollBar().setValue(page.insights_scroll.verticalScrollBar().maximum())
monkeypatch.setattr(page, "_load_detail", lambda record, **_kwargs: page._reset_detail_content(record))
page._select_record({"id": 52, "patient_name": "另一位患者"})
_settle(application)
assert page.insights_scroll.verticalScrollBar().value() == 0
assert all(label.text() == "正在加载…" for label in page.patient_summary_labels.values())
page._render_detail_load_failure("详情暂不可用")
assert all(label.text() == "未记录" for label in page.patient_summary_labels.values())
assert all(label.property("missing") is True for label in page.patient_summary_labels.values())
+85 -4
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow, QWidget
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
@@ -92,7 +94,7 @@ def test_modal_exec_uses_main_window_geometry() -> None:
main.close()
def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> None:
def test_business_windows_and_standalone_readonly_keep_main_window_85_percent() -> None:
app = QApplication.instance() or QApplication([])
_install_business_dialog_styling(app)
main = QMainWindow()
@@ -104,7 +106,6 @@ def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> Non
dialogs = [
AppointmentDrawer({"id": 501, "diagnosis_id": 501}, repository, main, autoload=False),
DiagnosisDialog(repository, main),
OrderDetailDrawer(main),
PrescriptionEditorDialog(repository, parent=main),
]
try:
@@ -121,6 +122,86 @@ def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> Non
main.close()
def _client_geometry(widget):
return QRect(widget.mapToGlobal(QPoint(0, 0)), widget.size())
def _settle(app):
for _ in range(4):
app.processEvents()
@pytest.mark.parametrize("view_only", [False, True])
def test_diagnosis_drawer_scrim_covers_the_entire_workstation_client_area(view_only) -> None:
app = QApplication.instance() or QApplication([])
_install_business_dialog_styling(app)
main = QMainWindow()
main.setProperty("doctorMainWindow", True)
main.setGeometry(30, 50, 1400, 900)
page = QWidget(main)
main.setCentralWidget(page)
main.show()
_settle(app)
dialog = DiagnosisDialog(object(), page, permissions=PermissionSet(["tcm.diagnosis/edit"]))
try:
dialog.open_for(501, editable=not view_only, view_only=view_only,
authoritative_detail={"diagnosis": {"id": 501, "patient_id": 1501}})
_settle(app)
def assert_full_coverage():
assert _client_geometry(dialog) == _client_geometry(main)
assert dialog.drawer_overlay.rect() == dialog.rect()
assert dialog.drawer_panel.width() == round(main.width() * 0.60)
assert dialog.drawer_panel.mapTo(dialog, QPoint(0, 0)).x() + dialog.drawer_panel.width() == dialog.width()
assert dialog.drawer_panel.height() == dialog.height()
image = dialog.grab().toImage()
shade = image.pixelColor(2, 2)
assert shade.alpha() > 0
assert image.pixelColor(2, image.height() - 3) == shade
assert_full_coverage()
main.setGeometry(70, 80, 1200, 780)
_settle(app)
assert_full_coverage()
main.showMaximized()
_settle(app)
assert_full_coverage()
main.showNormal()
_settle(app)
assert_full_coverage()
finally:
dialog.close()
main.close()
@pytest.mark.parametrize("standalone_readonly", [False, True])
def test_order_scrim_covers_its_diagnosis_owner_while_main_window_is_visible(standalone_readonly) -> None:
app = QApplication.instance() or QApplication([])
_install_business_dialog_styling(app)
main = QMainWindow()
main.setProperty("doctorMainWindow", True)
main.setGeometry(30, 50, 1400, 900)
main.show()
_settle(app)
owner = DiagnosisDialog(object(), main, permissions=PermissionSet(["tcm.diagnosis/edit"]))
owner.open_for(501, editable=not standalone_readonly,
authoritative_detail={"diagnosis": {"id": 501, "patient_id": 1501}})
drawer = OrderDetailDrawer(owner)
try:
drawer.show()
_settle(app)
assert _client_geometry(drawer) == _client_geometry(owner)
assert drawer.drawer_panel.width() == round(owner.width() * 0.8)
assert drawer.scrim.height() == owner.height()
main.setGeometry(70, 80, 1280, 820)
_settle(app)
assert _client_geometry(drawer) == _client_geometry(owner)
finally:
drawer.close()
owner.close()
main.close()
def test_maximized_workstation_opens_patient_picker_at_85_percent() -> None:
app = QApplication.instance() or QApplication([])
_install_business_dialog_styling(app)
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,26 @@
# AI 分析列显隐只读复核
日期:2026-10-17。已读根目录 `AGENTS.md`;项目没有 `.trellis/`,因此没有可读的 workflow/spec。本次只读代码与既有测试;仅新增本报告,未改业务代码或测试,未连接生产。
## 结论与最小修复
- `prescriptions.py:1276` 初始化隐藏第 11 列;`1697` 是唯一运行时直接修改第 11 列显隐的位置。目前 `_set_ai_columns()` 仅按服务开关 `enabled` 显示“AI 分析”,因此一致度未授权时仍显示该列。
- 建议第 11、12 列统一使用 `self._ai_enabled and self._agreement_visible` 决定显示。在 `_set_ai_columns()` 中按新条件处理两列;在 `_set_agreement_visible()` 中同时更新两列,覆盖能力查询开始、失败、撤权、授权恢复及 statuses 顶层 flag 变化。只改前者会遗漏撤权立即隐藏与后续授权恢复。
- 保留 `_ai_enabled = enabled` 的服务状态含义,不能改成 `enabled and agreement_visible`。行内“AI 报告”在 `1589` 依赖 `_ai_enabled`;复用该变量表示授权会误删用户要求保留的按钮。
- 第 11 列除此之外没有 `hideColumn/showColumn/setColumnHidden` 路径。`_render_ai_statuses()` 只更新内容;表头移动/列宽设置不会改变显隐,无需修改。
## AI 报告入口条件
- 行内按钮:`1589`,服务启用、同时具备 reports/detail 权限、非空白处方;不依赖一致度授权,应保持。
- 工具栏按钮:`1884–1890`,非空白处方、reports/detail 权限、仓库支持 `list_prescription_ai_reports`;不依赖服务开关或一致度授权,应保持。
- 右键菜单:`1828–1838`,reports/detail 权限、仓库支持 reports、非空白处方;受限时仍显示“AI 报告”。
- `can_open_issued_ai()` 在 `issued_prescription_ai.py:402` 只检查 reports/detail 权限。现有统计与报告详情的一致度门禁不需要因本次列显隐要求改动。
## 必要回归
1. 服务启用但 capability 为 false、缺失或非布尔 true,以及 capability 查询失败:第 11、12 列均隐藏,工具栏与行内“AI 报告”仍存在;root/`*` 不豁免。
2. capability 已允许但 statuses 顶层 `agreement_visible` 未明确 true:两列隐藏;statuses 的 `enabled` 仍为 true,报告入口可用。
3. 已授权显示两列 → 重新查询能力时立即隐藏 → 撤权或失败保持隐藏,旧回调不能恢复;重新明确授权且 statuses 允许后恢复两列。
4. 保留服务关闭、无 statuses 权限、空白处方原行为;服务关闭仍可查看历史报告。
现有 `app/tests/test_issued_prescription_ai.py:813` 明确断言受限时第 11 列仍显示,需随新要求反转;该测试已覆盖能力失败、撤权、旧回调、工具栏报告保留,可扩展行内按钮断言。`test_list_rechecks_completed_scores_after_hide_and_show` 可补两列恢复断言。建议离线更新/目视检查 `artifacts/prescription-ai-visibility/list-restricted.png`;原截图脚本已有受限列表 fixture。
@@ -0,0 +1,16 @@
# 未授权账号隐藏 AI 分析列
2026-10-17。用户补充范围:未被指定授权的账号隐藏已开处方列表“AI 分析”列,保留“AI 报告”按钮。
修改 `app/src/doctor_workstation/ui/pages/prescriptions.py` 的两个列显隐入口,统一按分析服务启用且当前账号获授权显示第 11/12 列。能力查询开始、失败、撤销、后续状态响应均会更新两列;服务状态仍独立保留,工具栏及行内报告入口继续可用。
验证结果:
- 修复前定向 5 个场景失败,复现未授权及能力查询失败后 AI 分析列仍显示。
- `test_issued_prescription_ai.py`:142 passed。
- `test_prescription_ui.py`:23 passed。
- 最终补充行内报告入口断言后,定向 5 个场景通过,含 toolbar 报告点击、root 不豁免、缺失/非布尔能力及旧回调。
- Ruff、限定变更文件 `git diff --check` 通过。
- 使用真实主题与内存数据离屏核对 [预览](ai-analysis-column-hidden.png):两列已隐藏,AI 报告入口保留。
本次仅修改桌面端源码和测试,未打包部署、不涉及新增 SQL;前次授权配置迁移若已执行,本次无需再执行数据库变更。更新桌面端后生效。
@@ -0,0 +1,87 @@
面诊文字对话:后端与桌面端只读调查
调查日期:2026-10-20
范围:仅代码、SQL 定义与无数据库契约;未读取 .env/凭据,未连接数据库,未查询患者数据,未修改业务代码。
结论
1. Web 新增“面诊文字对话”页签可以直接复用现有 getCallRecords,不需要新增 ASR、转写写入或全文详情接口。
2. 一次面诊在当前数据契约里对应一条 tcm_call_record,以 id / call_record_id 标识。全文 transcript_text 已逐通话存储。
3. 当前 HTTP getCallRecords 有登录验证,但控制器和查询逻辑未做诊单行级可见性验证。应补 canViewReadonlyDiagnosis,不能仅靠前端页签权限。
4. 没有独立转写分段 read 接口。现有 HTTP 列表不返回 transcript_segments;仅需已保存的完整文字时不存在接口缺口,需要逐句时间戳/说话人结构时才需要额外 read 能力。
一、现有读取接口与字段
- API:GET /adminapi/tcm.diagnosis/getCallRecords?diagnosis_id=<positive diagnosis id>。
前端请求封装使用 /tcm.diagnosis/getCallRecords,admin/src/api/tcm.ts:226。
- 控制器:server/app/adminapi/controller/tcm/DiagnosisController.php:430,读取 GET 参数,仅验证 diagnosis_id 非空,随后调用 DiagnosisLogic::getCallRecords。
- 逻辑:server/app/adminapi/logic/tcm/DiagnosisLogic.php:1816。
CallRecord::where('diagnosis_id', $params['diagnosis_id'])->order('id', 'desc')->select()->toArray()。
无 page_no/page_size/limit/paginate/count;返回该诊单全部通话记录,顺序为 id 降序,不应假设是按 appointment 分页。
- JSON 外层沿用 JsonService::data:{code: 1, show: 0, msg: '', data: [<record>, ...]}。
Web request 封装和桌面 client 均使用解包后的数组。
- 返回完整 CallRecord 行,另添加:start_time_text、end_time_text、duration_text、recording_urls_list、recording_status_text、local_audio_urls_list、local_audio_status_text、transcription_status_text。
- 通话核心字段:id、diagnosis_id、caller_id、caller_type、callee_id、callee_type、call_type(1语音/2视频)、status(1进行中/2已结束/3未接听/4已取消)、start_time/end_time(Unix秒)、duration(秒)、room_id。
- 转写字段(2026_08_12_add_call_transcription_fields.sql):
transcription_session_id: string <=128
transcription_language: string <=32
transcription_status: '' | running | completed | partial | failed
transcription_segment_count: unsigned int
transcript_text: nullable longtext,全通话转写,包含“医生:/患者:/未知说话人:”前缀与换行
transcription_started_at / transcription_finished_at: Unix 秒,未发生时为0
- 状态展示:running=录音转写中;completed=文字已生成;partial=文字部分保存;failed=文字生成失败;其他/空值=未生成文字。
- start_time_text/end_time_text 由服务端 date('Y-m-d H:i:s') 生成;不要在前端将此字符串当 UTC 再转换。
- 查询异常当前被 logic 捕获后返回 [],控制器仍返回成功包装。前端能显示空记录,但无法靠此接口区分“真空列表”与后端查询异常;本次最小接入不应暗示不存在的数据一定已生成。
二、分段格式与持久化
- 服务端表:zyt_tcm_call_transcript_segment(实际表前缀由部署决定)。
字段:id bigint、call_record_id int、transcription_session_id string、segment_id string、speaker_user_id string、speaker_role string、timestamp_ms bigint、text string、create_time/update_time Unix秒。
- 唯一键:(call_record_id, transcription_session_id, segment_id),防止同一完成分段重试时重复存储。
- 写入接口 upsertCallTranscriptSegments 的 segments 数组项:
{segment_id: string<=160, speaker_user_id: string<=160, speaker_role: 'doctor'|'patient'|'unknown', timestamp: nonnegative integer, text: nonempty string<=4000 chars}
每批1到50条;未知 role 被规范为 unknown;timestamp 写入为 timestamp_ms,不应混用随访录音的 start_ms/end_ms/channel 分段格式。
- snapshot 读取按 call_record_id AND transcription_session_id 过滤,按 timestamp_ms ASC, id ASC 排序,将每项拼成角色标签 + ':' + 文本,用换行连接到 transcript_text,并同步 transcription_segment_count。
代码:DiagnosisLogic.php:2130 buildCallTranscriptSnapshot。
- 启动/上载/结束是 POST startCallTranscription、upsertCallTranscriptSegments、finishCallTranscription。它们不是读取接口。
start:要求该 call 仍为 status=1;同一 record 已绑定其他 session 拒绝。
upsert:session 必须匹配且 transcription_status=running,保存后即可在列表中看到已固化的部分全文。
finish:期望完成条数大于实际条数时将 completed 降为 partial;结束后保留已存文本。
- 不应仅按 status=completed 才允许查看文字:running/partial/failed 也可能已有有效的 transcript_text。
三、数据隔离与权限核对
- call_record_id / diagnosis_id:写入路径 resolveCallRecordForTranscription 用 id=call_record_id AND diagnosis_id AND caller_id=当前管理员 AND caller_type='doctor',防止串记录与越权写;快照再以 session 筛选。
- HTTP read:getCallRecords 目前只有 diagnosis_id WHERE,未使用 admin_id/adminInfo,也未调用 canViewReadonlyDiagnosis。CallRecord 和 BaseModel 没有隐藏的 DataScope 全局过滤。
因此诊单 A 的请求不会混入诊单 B 的行,但调用者能否读诊单 A 没有在该业务入口校验。前端对诊单详情已授权不能替代每个 read 入口的独立校验。
- 现成行域入口:DiagnosisLogic::canViewReadonlyDiagnosis (DiagnosisLogic.php:4339)。验证正数诊单/管理员、排除被删除诊单;医助 role2 限本人 assistant_id;启用 DataScope 时按 visibleAdminIds 收窄;不存在与越权共用错误文案。
- 全局 LoginMiddleware 要求 token(此控制器没有免登录声明);AuthMiddleware 验证登录 IP 和菜单权限,但未登记的 action 会走 legacy bypass。没有任何通用行级诊单过滤。
- 转写 POST 的权限迁移 2026_08_12_call_transcription_permissions.sql,将三个写操作作为 tcm.diagnosis/video-call 子权限分配;不能将这一“发起通话”权限直接作为历史文字查看的唯一权限。
- 现有 Web CallRecordPanel 和桌面诊单视频Tab均以 tcm.diagnosis/huifang 控制可见。兼容的最小 read 约束是:在 AuthMiddleware 未注册路由放行逻辑之前,针对规范化后的 tcm.diagnosis/getcallrecords 精确判断管理员具有 tcm.diagnosis/huifang 或 tcm.diagnosis/getcallrecords;保留既有 root 豁免,然后由控制器执行 canViewReadonlyDiagnosis。
不要给历史 read 添加 edit 强制条件,也不要限定 caller_id=当前用户,否则可见诊单内其他医生的历史通话会被误挡。新的文字Tab应复用 huifang,或与直接getCallRecords权限做同样OR;权限撤回时退回基本资料并清空文字。
- MCP catalog/review/tcm.php:86 对 getCallRecords 有显式 canViewReadonlyDiagnosis guard。但该 guard 只用于 MCP 包装入口,不能证明 HTTP 控制器已经受到保护。
- appointment_id:仅 getCallSignature/startCall 用 AppointmentCallPolicy 检查允许通话方式;startCall 创建记录没有写 appointment_id。当前 CallRecord SQL与模型也没有该字段。
因此按 diagnosis_id 返回的是该诊单历次通话,不能声称按 appointment_id 精确隔离。当前需求按每条通话分组可实现;如果产品必须对应某一挂号/预约,则需要持久化 appointment_id 与历史迁移策略,超出此次最小只读接入范围。
- 2026-10-24医生网页面诊停用策略只拦签名/发起动作,不拦 getCallRecords;历史文字仍可查看,符合本次读取功能。
四、native 桌面端现状
- app/src/doctor_workstation/services/repository.py:2448 list_call_records:只校验诊单ID>0,然后 GET tcm.diagnosis/getCallRecords,参数只有 diagnosis_id;没有逐条 transcript 详情请求。
- ui/dialogs/diagnosis.py:2788,在视频Tab懒加载 list_call_records / get_call_records;返回时以 diagnosis_id 和 generation 双重校验,防止旧请求覆盖新诊单。
- ui/dialogs/diagnosis.py:3886 _call_transcript_text:先读 transcript_text,兼容 call_transcript/conversation_text/transcript;只有没有全文时才尝试 transcript_segments/segments,将 speaker_role 映射为中文角色并拼接文本。
这个 fallback 是兼容能力,不能当作服务器列表实际返回 segments 的证据。
- ui/dialogs/diagnosis.py:4074:某行有文字且 call_record_id>0 时显示“查看文字”,将该行全文与id直接传入只读 QPlainTextEdit 对话框。无网络详情调用,也不会合并其他通话。
- mock_repository.py 中示例 record 的 transcript_segments 项格式与存储表不同:写入上来的 segment 字典被保留,通常具有 segment_id/speaker_user_id/speaker_role/timestamp/text;不能据 mock 声称真实 HTTP 有此字段。
- 已有桌面测试 app/tests/test_diagnosis_order_video_visual.py:479 覆盖云端回放、本机音频、转写文字分别显示,并将“查看文字”按钮绑定该行id。
五、建议的最小 Web 接入
- 新增“面诊文字对话”Tab,按当前 diagnosis_id 懒加载现有 getCallRecords。
- 以 record.id 选中单次通话,显示开始时间、时长、通话/转写状态与 transcription_segment_count;全文取该行 transcript_text,以纯文本保留换行显示(不要 v-html)。
- 数据仍按服务端 id 降序,默认选最新有文字记录或最新记录;列表保留没有文字的通话,说明未生成/进行中/部分保存/失败,避免误解为缺少该次面诊。
- 诊单切换时先清空 records/selection;用请求序号或 diagnosis_id 检查忽略旧响应;同诊单手动刷新尽量保留选中的 record.id。
- 如果全部记录都没有全文,应显示“暂无已保存的面诊文字”,不能提示点击生成/补采集,因为本次不接 ASR 与写接口。
- 不要求新后端 detail 接口、不增数据表、不调用转写 POST。同步补齐既有 read 的行域和功能权限即能满足本次范围。
- 若将来需要细粒度分段,则新增只读详情必须接受诊单id+通话id,并服务端验证 call_record_id属于该诊单、诊单可见;查询以记录当前 transcription_session_id精确过滤,分页稳定按timestamp_ms,id,切勿仅凭传入session读任意分段。
六、无数据库验证
1. 已运行:php server/tests/CallRecordLifecycleIdentityContractTest.php
结果:Call-record lifecycle identity contract: OK(反射/源码契约,无数据库)。
2. 已运行仅 pathlib 读取源码的 Python 合同检查,13项全部 PASS:
diagnosis WHERE、id降序、无分页、read不加载分段、状态标签、snapshot call+session双过滤、稳定排序、换行拼接、longtext迁移、唯一键、桌面diagnosis-only GET、桌面先取全文、桌面旧响应保护。
3. 未跑网络/数据库接口;不能确认各部署实际迁移执行情况、菜单登记数据、既存患者是否有转写。上线前由已有后端流程保证转写字段迁移已部署。
本报告完成时未修改业务文件;仅新增本调查交付文件。以上行号为调查时定位,主线程随后补授权时可能改变。
@@ -0,0 +1,57 @@
面诊文字对话:后端授权回归验证
日期:2026-10-20
结果:本轮要求的测试与语法检查全部通过。
新增测试文件
server/tests/ConsultationTranscriptAuthorizationTest.php
测试方法
- 通过 Composer autoload 加载并执行实际 DiagnosisController::getCallRecords 与 AuthMiddleware::handle。
- 控制器派生类只提供合成 request、管理员身份及 JSON 包装;不替换待测动作。
- DiagnosisLogic 的授权/读取依赖、AdminAuthCache、LoginLogic 与 JsonService 使用进程内替身;所有记录和身份均为合成数据。
- middleware 的 request() 使用固定测试IP;env() 替身一旦调用即抛错。因此本测试不初始化框架、不读取 .env、不访问数据库或网络。
- 无菜单注册的替身 getAllUri 返回 [],并记录调用次数;专门断言该敏感路由完全不落入 legacy 未登记绕过路径。
- 本测试验证控制器会调用既有行级策略并在拒绝时停止读取;行级策略本身的语义继续由 DiagnosisWorkspaceRowAuthorizationTest 覆盖。本轮没有访问真实患者或数据库。
新增测试覆盖(57个断言)
1. 菜单中未注册 getCallRecords 时,无功能权限仍拒绝。
2. 仅有 edit/detail/video-call 或相似前缀权限不能读取面诊记录。
3. huifang 或 getCallRecords 任一精确权限单独即可读取,无需 edit;两者同时存在可用。
4. 路由与直接权限的大小写规范化不形成绕过。
5. root 保留原中间件权限豁免,且控制器仍调用诊单行级校验。
6. 诊单无权读取/不存在时,在 getCallRecords 读取依赖执行前拒绝,错误为空时使用统一兜底文案。
7. 缺失、null、0、负数、空串、非数字字符串 ID 被拒绝,不执行行级策略或读取。
8. 合法数字字符串 ID 规范成整数;实际行级授权收到可信 adminId/adminInfo。
9. 外部 call_record_id/appointment_id/caller_id 参数不会透传为额外的读取控制项。
10. 正常返回保留两个不同通话 ID、不同 caller_id(均非当前用户)、session、状态、全文及顺序,不强加 caller 限制。
11. 全文里的换行、中文与 <test>/& 保持原样;partial 记录的已保存文字不会被清除。
12. 已授权空记录仍返回原契约 data: []。
已执行命令及结果
php server/tests/ConsultationTranscriptAuthorizationTest.php
PASS: Consultation transcript authorization: 57 assertions passed (no DB or network)
php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php
PASS: Diagnosis workspace row authorization: OK
php server/tests/CallRecordLifecycleIdentityContractTest.php
PASS: Call-record lifecycle identity contract: OK
php -l server/app/adminapi/controller/tcm/DiagnosisController.php
PASS: No syntax errors detected
php -l server/app/adminapi/http/middleware/AuthMiddleware.php
PASS: No syntax errors detected
php -l server/tests/ConsultationTranscriptAuthorizationTest.php
PASS: No syntax errors detected
以上进程退出码均为0。
已向主线程说明的校验边界
本轮控制器版本先将 diagnosis_id 强转为 (int),再检查 >0。因此它拒绝上面第7项的输入,但尚不是严格正整数字符串/整数形状校验。
例如 '701suffix'、'701.5' 或 true 可被 PHP 强转为正整数。这些最终仍须通过诊单行级可见性验证,不构成跳过授权;但不应将当前输入形状表述为“已严格拒绝所有非法ID”。
主线程已知悉,并计划在本轮终态之后收紧形状验证,随后复跑本测试。严格形状校验当前不计入57项通过断言,尚未声称已完成。
变更范围
本轮仅新增上述后端测试与本验证报告;未修改业务代码、已有测试、部署文件或用户脏文件。
主线程最终补充验收
- 已将 diagnosis_id 收紧为整数或字符串输入,再经 FILTER_VALIDATE_INT/min_range=1 验证;小数、尾缀字符串、布尔、数组和对象均被拒绝。
- 已将这些输入形状加入同一无数据库控制器/中间件测试。最终重新执行输出:Consultation transcript authorization: 73 assertions passed (no DB or network),退出码0。
- 因此上文“整数强转”边界描述仅对应首次验证快照,最终交付代码已处理该问题。
@@ -0,0 +1,109 @@
# 医生网页面诊迁移:后端只读调查
调查日期:2026-10-20。确认截止时刻:**北京时间 2026-10-24 00:00:00(10 月 23 日结束后)**,即 UTC 2026-10-23 16:00:00,Unix 秒 `1792771200`。应按 `now >= cutoff` 判定到期,不能在 10 月 23 日零点提前禁用。
本文只调查现有实现并给出建议;未改业务代码,未读取 `.env`、数据库内容、令牌或密钥。已有脏文件保留。
## 结论
本次最小准确业务范围是:**到期后,仅禁止医生从网页获取新的通话签名及发起新的通话**。建议统一策略作用于 `tcm.diagnosis/getCallSignature` 和 `tcm.diagnosis/startCall`;医助、管理员、医生桌面工作站保持原权限。列表、历史病历、处方和通话收尾接口不需要随迁移公告一起禁用。
最关键的兼容问题:**医生 Python 桌面应用和网页当前均用 `terminal=1` 登录**,不能直接以 `terminal == PC` 区分它们。若本次以明确的工作站 User-Agent 识别新桌面,并兼容旧 `python-httpx/...`,这是常规客户端迁移识别方案,不是不可伪造的客户端认证;浏览器 `Origin`、`Sec-Fetch-*` 等信号应优先判为网页。长期更稳妥的办法是独立医生工作站终端与会话,但会涉及已安装客户端和旧令牌的迁移,本次不必扩大。
## 现有公告机制
- `server/app/adminapi/controller/notice/NoticeController.php`:`settingLists()`、`detail()`、`set()` 是“通知设置”的管理接口。
- `server/app/adminapi/logic/notice/NoticeLogic.php:36`:`detail()` 从 `NoticeSetting` 取 `system_notice/sms_notice/oa_notice/mnp_notice`;`system_notice` 是场景通知模板(标题、内容、启用状态)。`set()` 更新现有模板配置,未提供接诊台或问诊列表的全局公告发布/读取接口。
- `server/app/common/enum/notice/NoticeEnum.php`:目前主要为登录/绑定/换绑/找回密码验证码场景,不是医生客户端迁移公告。
- `server/app/adminapi/logic/ConfigLogic.php:51` / `controller/ConfigController.php:40`:`getConfig()` 是免登录基础配置,目前只有网站名称、logo、版权、版本等,无系统公告字段。
因此,两页面共用一个迁移公告组件/配置即可,不建议把一次客户端迁移伪装成现有验证码通知场景。若需要服务端统一时间和文案,可新增小的只读政策响应或向基础配置附加公开政策;服务端执行拦截时仍必须独立使用服务器时间。
## 医生身份与角色
| 证据 | 当前语义 |
| --- | --- |
| `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:22` | `DOCTOR_ROLE_ID = 1`,`ASSISTANT_ROLE_ID = 2`,团队角色 `[3,7,8]`,root 优先放行 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php:3118` `getDoctors()` | 联表 `admin_role`,`role_id=1` 获取医生 |
| 同文件 `getAssistants()` | `role_id=2` 获取医助 |
| `server/app/common/cache/AdminTokenCache.php:89` | 缓存含 `admin_id/root/role_id/terminal`,`role_id` 是数组 |
| `server/app/adminapi/logic/auth/AdminLogic.php:319` | `myself` 返回 `user.role_ids` 整数数组,适合前端公告受众与禁用状态使用 |
后端判定应该使用可信的登录身份,角色值先转整数,不能用前端提交的角色、姓名或“有接诊菜单”代替医生身份。需先排除 `root=1`。单纯拥有医助/团队角色且没有角色 1 的账号应放行;多角色 `[1,2]` 或 `[1,3]` 需要明确定义医生身份优先级,至少加入回归用例,避免依赖角色数组第一项。按“含角色 1 的非 root 为医生”最接近现有医生列表语义;如业务要对同时拥有管理角色的医生豁免,需显式列出豁免角色,不能猜测。
## 终端认证与桌面兼容
| 文件/方法 | 证据 |
| --- | --- |
| `admin/src/config/index.ts:2` | 网页 `terminal: 1` |
| `admin/src/api/user.ts:5` `login()` | `/login/account` 带 `terminal: config.terminal`;企微网页登录亦如此 |
| `app/src/doctor_workstation/services/repository.py:842` `login()` | 医生 Python 桌面明确发送 `{"account": ..., "password": ..., "terminal": 1}` |
| `app/src/doctor_workstation/services/api_client.py:477` `_headers()` | 自定义头目前仅 `Accept`、`version`、`Content-Type`、`token`;没有专用医生工作站身份头或 User-Agent |
| `server/app/common/enum/AdminTerminalEnum.php:26` | `PC=1`、`MOBILE=2`、`WECOM_RPA=7`、`WECOM_RPA_MOBILE=9`;8 被 AI 浏览器占用 |
| `server/app/adminapi/validate/LoginValidate.php:34` | 账号登录只接受 1/2/7/9 |
| `server/app/adminapi/service/AdminTokenService.php:41` `setToken()` | 按 `(admin_id, terminal)` 找/复用会话,故终端 1 不只是枚举冲突,也会共用会话维度 |
| `server/app/common/cache/AdminTokenCache.php:98` | `terminal` 从持久化 `AdminSession` 进入请求身份 |
**7/9 不是医生工作站终端。** `server/app/adminapi/controller/DesktopController.php` 的 `session()` 是企微客服软件换取客服身份的接口,只允许 `wecomRpaTerminals()`;不能为医生工作站借用 7 或9,否则会扩大客服后台身份的可换取范围。
推荐的即时方案:给医生桌面设置 `ZytDoctorWorkstation/<version>` User-Agent;服务器在浏览器信号不存在时识别这个明确前缀,并按部署范围兼容旧 `python-httpx/<version>`。旧桌面兼容策略应写测试并在研究/变更说明中如实表述其可信边界。不要仅接受任意请求提交的 `desktop=1` 即放行。
若以后分离医生工作站终端,应增加新的未占用枚举、登录验证、Python 登录参数,保留既有账号和权限逻辑;旧终端 1 的持久化 token 不会自动变成新终端,升级后应清理/重新登录或安全换取新会话。`LoginController::workWechatLogin()` 当前直接收 `terminal`,未使用 `LoginValidate`,有新终端方案时也应统一处理其允许值。
## 面诊/问诊调用入口
| 接口/方法 | 当前行为与建议 |
| --- | --- |
| `DiagnosisController::getCallSignature()`(334)→ `DiagnosisLogic::getCallSignature()`(908) | 校验诊单/患者身份、行级范围,生成 `doctor_<adminId>` 的 TRTC/IM 签名,签名有效 **86400 秒**。**应作为主要迁移拦截入口**,在签名生成/IM账号导入前拒绝到期的医生网页请求。 |
| `DiagnosisController::startCall()`(350)→ `DiagnosisLogic::startCall()`(1520) | 校验患者、数据范围、挂号音视频策略后创建进行中通话记录。**应加相同拦截**,覆盖已缓存签名后重新开始的请求。不能把它等同于腾讯SDK本身的鉴权。 |
| `doctor.appointment/reception` / `AppointmentLogic::reception()`(645) | 接诊台聚合详情,已有挂号/数据范围检查;属于查看数据,建议保留。 |
| `tcm.diagnosis/lists/detail/readonlyDetail/trackingWindow` | 列表/历史病例查看,医助与管理员也使用,建议保留。 |
| `doctor.appointment/complete` / `AppointmentLogic::complete()`(583) | 标记完成、通知医助、可能系统代开空处方;不是开始通话,建议不在本次一起禁用。 |
| `tcm.prescription/add/edit/audit/void/delete` | 处方动作有独立鉴权/别名策略,本次“网页到期无法面诊”不应自动扩大到开方、审核或药师工作。 |
| `tcm.diagnosis/edit`、`doctor.appointment/addDoctorNote` | 病例、诊疗笔记写入,和通话迁移不是同一权限,建议保持现状。 |
| `bindCallRoom/endCall/startCloudRecording/uploadCallRecording/attachLocalCallRecording/startCallTranscription/upsertCallTranscriptSegments/finishCallTranscription` | 当前通话房间绑定、结束、录音/文字落盘流程。建议保留原权限,避免跨截止时刻进行中的通话丢失记录;尤其不能一刀切封 `tcm.diagnosis/*`。 |
| `tcm.diagnosis/watchCall` | 医助只拉流旁观,`getAssistantWatchRoomParams()` 另校验指派医助,保留。 |
| `tcm.diagnosis/getDoctorSignature`(631) / `getPatientSignature`(649) | 历史测试签名入口;`admin/src/views/test/patient-call.vue:308` 用前者。不是当前常规医生面诊链路。需要记录为边界:如果本次要求阻止所有可用签名获取途径,应对医生网页同样限制这两个入口;医助仍需兼容,不能全局删除。 |
网页主链路证据:`admin/src/views/patient/reception/index.vue:439` 的 `handleCall()` 先调 `getCallSignature()`,再打开 `chat-dialog`;挂号页 `admin/src/views/tcm/appointment/list.vue`、`list_h5.vue` 也发同一签名请求。问诊聊天组件 `admin/src/components/chat-dialog/index.vue` 会再次取签名并创建通话记录。
### 必须处理的旧页面边界
- 由于签名有效24小时,截止前已拿到签名并已登录腾讯IM/TRTC的网页,不会因服务器后续拒绝签名自动退出。
- `admin/src/components/chat-dialog/index.vue:769` 的 `ensureCallRecordStarted()` 捕获 `startCall()` 失败后只 `console.warn`,因此“拒绝落库”本身未必阻止腾讯SDK呼叫。
- 为覆盖真实使用场景,前端需要在通话按钮/呼叫前钩子再次判定到期,并在页面保持打开期间跨越截止时刻时更新状态;后端两入口作为常规请求的最终防线。只隐藏接诊台按钮不足以覆盖挂号页、H5或已经打开的聊天弹窗。
- 若要求对持有旧签名的非标准客户端也强制断开,需要腾讯侧会话/签名吊销或更强设备认证策略,不属于仅新增公告的最小变更范围。
## 放置策略与响应
现有链路为 `server/app/adminapi/config/route.php`:Init → Login → Auth → CheckDemo。`LoginMiddleware` 验证 token 并设置 `request.adminInfo/adminId`,AuthMiddleware 存在 root 放行与未注册路由绕过。
统一迁移策略适合在登录身份已加载后按两个精确控制器/动作调用,或放在两个控制器方法开始处调用同一个 helper;不要依赖新权限菜单存在才触发,也不要在登录时整账号封禁。政策判断应顺序处理:精确面诊入口 → 截止时刻 → root/医生角色 → 桌面识别 → 返回迁移提示。
失败响应可沿用 `JsonService::fail()` 的 `code=0` 业务失败,附稳定的迁移标识/截止数据方便页面呈现;不要用 `code=-1`,否则前端会当登录过期退出并丢失工作上下文。不要复用企微绑定 `code=10`。
日期建议使用带时区的不可变时间对象(`Asia/Shanghai` 或明确 `+08:00`),或固定 epoch;`server/config/app.php` 当前默认时区虽为 `Asia/Shanghai`,但不应依赖宿主机器时区解析这个业务截止。
## 验证方式
本次已执行既有只读契约测试(均只 require autoload、反射源码或调用纯策略,未启动业务服务):
```powershell
cd D:\web\zyt\server
php tests/CallSignatureIdentityContractTest.php
php tests/DiagnosisWorkspaceRowAuthorizationTest.php
php tests/AdminDesktopAuthContractTest.php
```
结果全部通过:`Call signature identity contract: OK`、`Diagnosis workspace row authorization: OK`、`OK`。
实施后应加一组无数据库政策测试,注入当前时间并覆盖:
1. 截止前1秒、截止当秒、截止后1秒,且进程默认时区换为UTC仍一致。
2. 网页医生 `root=0, role_id=[1], terminal=1` 在两入口被拒绝;医助 `[2]`、管理员 root、无医生角色的团队账号保留。
3. 新工作站UA和旧Python桌面UA按兼容策略放行;带Origin/Sec-Fetch的浏览器即使声称桌面UA也不意外放行;正常浏览器、空UA/未知UA在到期后作为网页处理。
4. 多角色数组、字符串数字角色、空身份的明确语义。
5. 只读列表/病例/处方、结束通话、录音上传、医助旁观等非拦截路由仍通过策略。
6. 前端在截止前打开并持有签名,跨到截止后不能从聊天栏或音视频工具栏发起新呼叫;进行中通话的结束和录音上传仍完成。
测试环境可用时间注入,不要修改生产系统时间或依赖测试机当前日期。未进行真实患者呼叫、真实开方或任何生产写操作。
@@ -0,0 +1,63 @@
# 医生网页面诊迁移:后端与桌面客户端独立验证
验证日期:2026-10-20。只读审查与本地契约测试;未修改业务代码,未读取 `.env`,未调用真实业务接口,未处理其他现有脏改动。
## 结果
**指定检查全部通过,未发现本次后端与桌面请求变更的阻塞性问题。** 截止时刻、医生受众、精确通话入口和新旧桌面请求兼容性均与迁移要求一致。
| 检查 | 结果 |
| --- | --- |
| `php tests/DoctorWebConsultationPolicyTest.php` | 通过,62 条断言 |
| `DoctorWebConsultationPolicy.php` 语法 | 通过 |
| `DoctorWebConsultationMiddleware.php` 语法 | 通过 |
| `config/route.php` 语法 | 通过 |
| `logic/auth/AdminLogic.php` 语法 | 通过 |
| `tests/DoctorWebConsultationPolicyTest.php` 语法 | 通过 |
| `php tests/CallSignatureIdentityContractTest.php` | 通过 |
| `php tests/CallRecordLifecycleIdentityContractTest.php` | 通过 |
| `php tests/AppointmentCallPolicyTest.php` | 通过,34 条内存模型断言,不访问数据库 |
| `php tests/AdminDesktopAuthContractTest.php` | 通过 |
| `app/.venv-runtime/Scripts/python.exe -m pytest tests/test_api_client.py -q` | 通过,16 项测试 |
| HTTPX 原生默认头 / 新 ApiClient MockTransport 请求 | 通过,实际观测旧 UA `python-httpx/0.28.1` 和新 UA `ZytDoctorWorkstation/1.4.8`,两者均不附浏览器 Origin/Referer/Sec-Fetch 头 |
首次使用全局 `python` 运行时因该解释器未安装 pytest 失败;随后使用项目现有 `.venv-runtime` 成功完成指定测试,没有安装或更新依赖。PHP 使用本机 `D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe`。
## 实现审查
### 日期
`server/app/adminapi/service/DoctorWebConsultationPolicy.php` 的 `CUTOFF_AT` 明确为 `2026-10-24T00:00:00+08:00`,即北京时间 10 月 23 日结束后。`DateTimeImmutable` 带明确偏移量,不依赖宿主机时区。`now < cutoff` 放行,截止当秒起执行限制。政策测试在 UTC 默认时区覆盖截止前一秒、截止当秒和后一秒。
`publicPolicy()` 返回相同 `cutoff_at` 和秒级 `server_time`,`AdminLogic` 将其附在 `myself` 的 `user.web_consultation_policy`;未给前端另造不同截止时刻。
### 受众与路由范围
策略只对可信登录身份中 **非 root 且角色数组包含医生角色 1** 的请求生效,角色先转换为整数。医助 `[2]`、无医生角色的管理团队 `[3,7,8]` 和 root 保持原有权限。多角色 `[2,1]` 仍按医生处理,与已写明的政策一致。
限定控制器 `tcm.diagnosis` 和四个动作:
- `getCallSignature`
- `startCall`
- `getDoctorSignature`
- `getPatientSignature`
后两个是此前调查发现的历史测试签名入口,限制它们避免常规签名入口停用后仍可从旧测试页取新签名。没有扩大到处方、病历编辑、预约完成、历史查看、医助旁观或通话收尾。
`DoctorWebConsultationMiddleware` 注册在 Login/Auth 之后,拿已验证身份执行检查;后续业务方法在策略拒绝时不会运行。失败沿用 `code=0`,带 `reason=doctor_web_consultation_expired` 和截止信息,不会触发登录过期流程。root 豁免在独立策略里再次明确,因此原 AuthMiddleware 的 root 提前放行不会破坏本次语义。
### 桌面兼容
没有错误地把 `terminal=1` 统一视为网页。`ApiClient._headers()` 增加 `ZytDoctorWorkstation/<__version__>`,当前源码版本 `1.4.8` 能匹配后端完整 UA 格式。API 客户端测试确认真正发出的请求带该 UA。
政策同时识别旧 HTTPX 默认 UA。额外用 `httpx.MockTransport` 捕获本地请求确认:未改旧模式的 HTTPX 客户端实际发 `python-httpx/0.28.1`,新 ApiClient 发 `ZytDoctorWorkstation/1.4.8`;均无浏览器信号。没有更改医生桌面登录终端、token头、API版本头或现有重试语义。
`Origin/Referer/Sec-Fetch-*` 优先于 UA;即使浏览器误带原生 UA 也会受限。空 UA、未知 UA、带额外前后缀的 UA 不会意外得到桌面豁免。策略测试覆盖这些情况。
## 已知边界
1. UA 是产品客户端兼容识别,**不是不可伪造的客户端认证**。能自行构造请求并持有有效医生 token 的调用方仍可仿造原生 UA。这是已选即时迁移方案的边界,不应宣传为设备级安全认证。
2. 后端拦截不会撤销截止前已签发且尚未过期的腾讯签名。已打开页面跨过截止时间后的呼叫应由本次前端呼叫前守卫处理;本次独立验证按分工不覆盖浏览器界面。真实腾讯呼叫、线上部署与已安装旧版程序没有进行端到端验证。
3. 受控测试证明拒绝路径不进入业务回调、旧原生请求允许继续,并保留 `endCall/bind/录音上传/文字落盘` 等收尾路由;这不替代部署环境中反向代理对请求头的实际行为验证。
以上边界没有扩大本次变更范围,也没有导致所运行测试失败。
@@ -0,0 +1,40 @@
医生网页面诊配置化:后端验证
日期:2026-10-22
配置入口:server/config/doctor_web_consultation.php
- cutoff_at 默认 2026-10-24 00:00:00,严格按 Asia/Shanghai 解析到秒。
- approaching_hours 默认 48,urgent_hours 默认 24;必须为非负整数且前者 >= 后者。
- 提醒日期由截止时间自动计算。修改此文件即可在后续接口请求读取,无须前端重新构建。
- 使用固定路径 require,绕过 ThinkPHP 已加载/缓存的配置;不做日期静态缓存。
- PHP 提供 opcache_invalidate 时,读取前主动失效该文件的字节码缓存。
接口与行为:
- 免登录只读 /config/doctorWebConsultation:cutoff_at、approaching_at、urgent_at、server_time。
- 登录用户资料继续通过同一 publicPolicy() 返回策略。
- 中间件截止判定、截止时间字段和停用文案均来自配置。
- 保留医生角色、root、精确动作范围、浏览器头优先和新版/旧版桌面 UA 兼容行为。
- 非法日期(含不存在的日历日期/当地时间)、时区后缀、非整数/负数/逆序/溢出小时数抛出明确字段错误。
- 不相关动作、非医生、root 和无浏览器信号的已知桌面请求先行放行,不因无效迁移配置扩大影响。
运行环境:PHP 8.2.9 CLI,Windows。
通过:
1. php tests/DoctorWebConsultationPolicyTest.php
Doctor web consultation policy: 133 assertions passed
覆盖默认前一秒/截止秒/后一秒、自定义 2032 年中午截止与跨年提醒、小时数修改、秒级消息、
多进程时区、无效配置、真实中间件、原生客户端兼容、真实配置控制器动作及免登录名单。
临时目录中仅修改专用配置,验证同一 PHP 进程立即读取新日期;同时保留旧 ThinkPHP 缓存值验证绕过缓存。
2. php tests/CallSignatureIdentityContractTest.php
Call signature identity contract: OK
3. php tests/AppointmentCallPolicyTest.php
Appointment call policy: 34 assertions passed (in-memory model, no database)
4. php tests/AdminMultiRoleRegressionTest.php
AdminMultiRoleRegressionTest passed
5. php tests/DesktopWorkstationUpdateContractTest.php
Desktop workstation update contract: OK
6. php -l 检查全部 5 个新增/修改的后端 PHP 文件通过。
限制:
- 未连接业务数据库或线上服务,未进行线上 HTTP 冒烟测试;控制器和中间件使用内存请求/响应测试替身。
- 本地 PHP CLI 未加载 OPcache,主动失效 OPcache 分支未做真实扩展环境验证。
- 使用真实 Asia/Shanghai 时区规则;历史夏令时或早期时区偏移会依照时区数据库输出,现代/未来日期输出 +08:00。
- 本次仅修改后端,不修改前端代码、不清理现有未提交变更。
@@ -0,0 +1,68 @@
Configurable doctor web consultation schedule - frontend verification
Verified on 2026-10-22 (Asia/Shanghai) in D:\web\zyt.
Scope
- Read the policy helper, shared useDoctorWebConsultation hook, runtime API,
shared notice, user clock integration, call guards, and targeted test coverage.
- No authored frontend source changes were made by this verifier.
Targeted regression tests: PASS
Working directory: D:\web\zyt\admin
Command: node --test tests/doctor-web-consultation.test.cjs tests/appointment-call-mode.test.cjs tests/consultation-transcripts.test.cjs
Actual process exit code: 0
Result: 27 tests, 27 passed, 0 failed, 0 cancelled, 0 skipped, 0 todo.
Log: D:\web\zyt\.codex-tasks\doctor-web-config-tests.log
Coverage observed includes configurable arbitrary cutoff dates and seconds,
Beijing timezone display, exact reminder/enforcement boundaries, server clock
offset, same-page clock transitions, click-time recheck, shared request
deduplication, minute polling, focus/visibility refresh, failed/invalid response
recovery retaining the last valid policy, public fresh policy requests, installer
availability throughout reminder phases, and notice compilation on reception,
diagnosis, and both appointment list entries. Related call-mode and transcript
regressions also passed.
Full Vue typecheck: EXISTING FAILURES ONLY; NO NEW DIAGNOSTICS
Command: node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --composite false --incremental false --pretty false
Actual process exit code: 2
Baseline: D:\web\zyt\.codex-tasks\consultation-transcripts-typecheck.log
Stable result log: D:\web\zyt\.codex-tasks\doctor-web-config-typecheck.log
Baseline diagnostics: 61
Stable current diagnostics: 61
New normalized diagnostic signatures: 0
Removed normalized diagnostic signatures: 0
Full-log normalized line differences: 0
Comparison removed (line,column) locations before comparing primary error
signatures and all log lines. The full project is not typecheck-clean; its
reported diagnostics match the supplied baseline.
The first typecheck ran concurrently with Vite's generation of ignored
admin/components.d.ts and temporarily reported 57 diagnostics with a changed
Element Plus template diagnostic set. It was not used for the final comparison.
After the build exited, typecheck was rerun to actual completion against stable
generated declarations; it produced the baseline-matching 61 diagnostics above.
Preserved concurrent-run log:
D:\web\zyt\.codex-tasks\doctor-web-config-typecheck-concurrent.log
Vite production build: PASS
Working directory: D:\web\zyt\admin
Command: node node_modules/vite/bin/vite.js build --outDir D:\web\zyt\.codex-tasks\doctor-web-config-build --emptyOutDir --logLevel error
Actual process exit code: 0
Log: D:\web\zyt\.codex-tasks\doctor-web-config-build.log
Output: D:\web\zyt\.codex-tasks\doctor-web-config-build
Before --emptyOutDir, the absolute normalized output path was verified to be
inside D:\web\zyt and to equal the exact requested output directory.
Produced 501 files totaling 41,475,816 bytes; index.html exists (31,872 bytes).
Built JavaScript includes the runtime consultation policy endpoint/helper.
Build log contains only a warning that caniuse-lite browser data is six months
old; no build error was reported.
Relevant implementation observations
- Schedule dates originate in the server response; the browser helper has no
independent cutoff constant.
- All reminder phases and call blocking consume the same normalized policy.
- The hook shares in-flight policy requests, refreshes every minute and on
focus/visibility, and retains the last valid policy when refresh fails.
- Expired blocking applies to doctor role 1 while excluding root accounts;
notices keep desktop installer links and refresh actions available.
- Verification did not exercise a live browser/backend or perform deployment.
@@ -0,0 +1,75 @@
医生网页迁移:安装包只读调查
核查日期:2026-10-20(Asia/Shanghai)
范围:只读源码、文档、本地产物;匿名 GET 公开更新接口;HEAD 已公布安装器 URL。未读取 .env/密钥,未访问管理配置/上传凭据接口,未下载远程大包,未安装/执行/发布安装器,未修改业务代码。
Trellis:D:/web/zyt/.trellis/workflow.md 不存在。
结论
1. 有真实可复用的 Windows x64 1.4.8 安装器,且当前线上已发布、匿名 HEAD 可读取,下载入口可直接复用现有公开更新契约。不需要为了本次网页迁移再造一套包上传功能。
2. “已实现下载入口”和“包已发布”是两件事。本次包发布事实由 2026-10-20 的生产 GET/HEAD 实测支持;本地 app/dist 产物单独只能证明本地存在。未做远程整包重新哈希或真实安装验收,不把 HEAD 当成完整安装验收。
3. 现有安装器不是本轮源码的重新构建产物。其最后修改时间为 2026-10-17;可以作为已有正式桌面端的迁移入口,不应声称包含本轮尚未重新打包的源码修改。
真实已发布 Windows 安装器
公开读取地址:
GET https://admin.zhenyangtang.com.cn/adminapi/setting.desktop_workstation/check?current_version=0.0.0&platform=windows&arch=x64
HTTP 200,JSON code=1。
data.enabled=true
data.latest_version=1.4.8
data.min_version=1.4.8
data.has_update=true
data.force=true
data.can_install=true
data.title=更新说明
data.notes(现有发布说明)=1.修复无法编辑诊单\n2.诊疗台增加医助标识\n3.修复AI回复慢
data.package.type=inno_setup
data.package.filename=DoctorWorkstation-Setup-Windows-x64-1.4.8.exe
data.package.size=171474334 字节(约 163.53 MiB)
data.package.sha256=792f2273bba9f1d92d3b9e8eacabedcc53755ac003c284d648a9dca105bfb321
data.package.url=https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/desktop_package/1/20261017/1792229692103-0288326cfce9f6dc.exe
该 package.url 的匿名 HEAD 验证:
HTTP 200
Content-Type: application/x-msdownload
Content-Length: 171474334
Last-Modified: Sat, 17 Oct 2026 09:34:52 GMT
ETag: "bc4b1f4762535d4e420928518c2e1a4a-33"
Accept-Ranges: bytes
Content-Disposition: 未提供
注意:COS 多段上传 ETag 不能当作 SHA-256。跨域 <a download> 也不保证浏览器强制使用 metadata.filename;下载按钮文案应明确为 Windows 安装包及版本。
本地产物独立核验
路径:D:/web/zyt/app/dist/DoctorWorkstation-Setup-Windows-x64-1.4.8.exe
大小:171474334
最后修改:2026-10-17T17:33:43.1648862+08:00
Windows ProductVersion:1.4.8(资源字段带尾部空格)
Get-FileHash SHA256:792F2273BBA9F1D92D3B9E8EACABEDCC53755AC003C284D648A9DCA105BFB321
与本地 SHA256SUMS.txt 及生产接口 package.sha256 完全一致。
Get-AuthenticodeSignature:NotSigned(本地当前安装器未签名);不要向用户标成“已完成代码签名”。
主程序 D:/web/zyt/app/dist/DoctorWorkstation/DoctorWorkstation.exe 的 FileVersion/ProductVersion 均为 1.4.8。
便携 ZIP D:/web/zyt/app/dist/DoctorWorkstation-Windows-x64-1.4.8.zip 存在,242411630 字节;SHA256SUMS.txt 记录 DF0F80AFA29825C42323C666F0C033EC0335C83A3DA6C7723ED2FA74C819CA85。本轮没有远程 ZIP 发布证据,不应在迁移页面承诺 ZIP 已上线。
安全复用读取方式
- server/app/adminapi/controller/setting/DesktopWorkstationController.php:28 显式 notNeedLogin=['check'];check 仅调用 DesktopWorkstationLogic::check($request->get())。
- 同控制器 getConfig 不是免登录接口,并配有管理菜单权限;医生页面不要调用管理设置 getConfig,也不要读取数据库或对象存储凭据。
- 推荐网页 GET 同源 /adminapi/setting.desktop_workstation/check,固定 current_version=0.0.0、platform=windows、arch=x64,从 data.package 提取 url/filename/size/type,显示 data.latest_version。
- server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:85-102 表明 package 在完整 URL/SHA 存在时返回;can_install 额外取决于 enabled 与版本比较。若希望迁移入口只分发当前已发布包,可按经过校验的 package 判断是否存在,避免把“自动更新开关关闭”误当成“没有任何包”;如果产品明确要求关闭自动更新也撤下下载,则同时要求 enabled/can_install。
- 实现时只接受 https: 的有效安装包 URL,并限制 type/文件格式为适配 Windows 的 inno_setup EXE(或明确另行支持的 archive ZIP);拒绝 javascript:/data:/空链接。链接使用 noopener/noreferrer;显示版本、适用系统、大小及必要的校验值。
- 当前已公开 package URL 无查询签名/令牌;可用配置动态取得,避免硬编码日期目录。读 GET 无需用户登录 token;也无需调用 COS STS。
- 请求失败、package 缺失或 URL 无效时,下载区显示“安装包暂未就绪,请联系管理员”,保留重试和明确的支持渠道。不要用 # 占位、猜测 URL,或把“按钮已写好”报告成“已提供安装包”。
现有打包/发布链路
- app/src/doctor_workstation/__init__.py:6、10、11:源码版本 1.4.8;DEBUG_MODE=False;公开生产根地址为 https://admin.zhenyangtang.com.cn。
- app/Build_DoctorWorkstation.bat 调用 app/scripts/package_windows.ps1;后者使用该单一版本源,构建 PyInstaller onedir,执行冻结应用/媒体门禁,生成 Inno Setup EXE、便携 ZIP、SHA256SUMS.txt。
- app/packaging/windows/doctor_workstation.iss:44、46:x64compatible,最低 Windows 10 1809(10.0.17763)。因此页面适用文案应为 Windows 10 1809+ / Windows 11 64 位;不能写 Windows 7 或笼统“所有 Windows”。
- app/README.md:12-23 及 app/packaging/README.md:34-40 已有正式打包和自动更新发布说明。
- admin/src/views/setting/desktop_workstation/index.vue 已有三个包槽 windows_x64/macos_arm64/macos_x64;Windows 默认 inno_setup。上传组件 type=desktop_package、direct,浏览器计算 SHA-256 并记录 filename/size;上传成功只填地址,点保存才 POST setConfig 发布策略。
- admin/src/components/upload/index.vue 使用 uploadDirectToCos;desktop_package 若未配置 COS 则直接报错,不回退业务服务普通上传;管理页面说明支持 EXE/ZIP、最大 2GB。
- server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:48-54 将 enabled/latest_version/min_version/force_update/title/notes/packages 存于 ConfigService 的 desktop_workstation 分类。不存在需要迁移页自行创建的安装包清单文件。
- macOS 有源码打包机制及槽位,但本轮未验证 macOS 在线包;只承诺已核查的 Windows 包。
若后续线上包缺失
1. 已有健康包:使用管理设置上传/填入经过核验的 HTTPS 产物,填同版 filename/size/SHA 后保存,然后匿名 GET + HEAD 检验发布契约。
2. 需要新源码才可用:在明确版本/tag 的原生 Windows 构建环境重新打包,完成现有安装/启动/卸载 smoke,再由授权发布流程上传及保存。不可把 latest_version 改新而继续绑定旧包(app/research/server_release_diagnosis.md 和 update_install_handoff_diagnosis.md 已记录过该类问题)。
3. 生产包尚不可用时:可以完成页面下载状态和失败提示,但上线通告中的“可下载安装”必须以真实发布验证为前提。本次已找到并核实 1.4.8,因此当前无需用缺包状态作为默认交付。
关于“分阶段提醒”的界限
现有升级配置 title/notes 是桌面自动升级内容,未发现网页停用的阶段调度字段。网页迁移的公告/截止前提醒/截止后阻断应使用本轮迁移策略;下载包元数据可在各阶段复用公开 check 接口。本轮调查没有创建自动化、发送通知或修改线上升级策略。
@@ -0,0 +1,67 @@
医生网页迁移补充:后端下载清单及安装包验证
日期:2026-10-20(Asia/Shanghai)
验证范围:后端新增 downloads()/downloadCatalog()、控制器免登录白名单、原更新契约和网页面诊政策回归、真实已发布 Windows 1.4.8 元数据。
操作限制:仅运行本地纯 PHP 测试、源码检查、生产公开更新 GET 和安装包 HEAD。未读取 .env/密钥,未执行真实上传/发布/业务写入,未下载安装包或执行安装。未修改业务代码或测试。
结果:本次指定验证全部通过。
PHP 运行环境
D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe
指定测试(全部退出码 0)
1. php server/tests/DesktopWorkstationDownloadsTest.php
输出:Desktop workstation downloads: 21 assertions passed
覆盖:仅 version/packages 顶层字段、可用平台、SHA 小写规范化、Windows inno_setup、平台标签、管理策略字段不泄露、enabled=0 仍能手动下载、无效/凭据嵌入 URL 和无效 SHA 被剔除、空清单、免登录白名单和控制器委托现有配置读取。
2. php server/tests/DesktopWorkstationUpdateContractTest.php
输出:Desktop workstation update contract: OK
覆盖:原版本比较、平台映射、可选/强制/最低版本更新、关闭自动检测、缺少平台包、更新 check 的公开契约,以及原安装包直传权限和菜单契约。
3. php server/tests/DoctorWebConsultationPolicyTest.php
输出:Doctor web consultation policy: 62 assertions passed
原网页面诊截止策略回归通过。
PHP 语法检查(每项退出码 0)
php -l server/app/adminapi/logic/setting/DesktopWorkstationLogic.php
php -l server/app/adminapi/controller/setting/DesktopWorkstationController.php
php -l server/tests/DesktopWorkstationDownloadsTest.php
php -l server/tests/DesktopWorkstationUpdateContractTest.php
四项均输出 No syntax errors detected。
源码核对
- DesktopWorkstationLogic::downloads() 仅返回 self::downloadCatalog(self::getConfig()),沿用已发布配置和现有 URL 补全逻辑,没有新增配置存储或上传行为。
- downloadCatalog() 固定遍历 windows_x64、macos_arm64、macos_x64。每个包先经 plainPackage(),最终仅有 platform/label/url/sha256/size/filename/type;顶层只有 version/packages,不包含 notes/title/min_version/force_update/enabled。
- 下载清单不依赖 enabled,关闭自动检测仍可供首次安装。原 evaluate() 未改变,关闭后仍不提示自动升级。
- DesktopWorkstationController 的 notNeedLogin 精确为 ['check', 'downloads']。getConfig/setConfig 未列入;downloads() 使用 data(DesktopWorkstationLogic::downloads())。
- BaseLikeAdminController::isNotNeedLogin() 按动作名检查该白名单;LoginMiddleware 对非免登录动作要求有效 token,AuthMiddleware 对免登录动作跳过管理菜单权限校验。管理设置读写没有被本次白名单扩大。
- downloadCatalog() 拒绝无 host、嵌入用户名/密码以及非 HTTP(S) scheme;实际生产安装器为 HTTPS。当前函数仍兼容 HTTP,未把“强制 HTTPS”作为实现事实。
真实已发布 1.4.8 元数据验证
读取:
GET https://admin.zhenyangtang.com.cn/adminapi/setting.desktop_workstation/check?current_version=0.0.0&platform=windows&arch=x64
匿名 GET 返回 HTTP 200、JSON code=1、latest_version=1.4.8。
将该 GET 的真实 data.package 传给本地新 downloadCatalog(),构造 enabled=0,同时加入未知平台 linux_x64 和额外 package 字段 extra,以及管理 title/notes。独立 PHP 断言全部通过:
- 顶层键精确为 version/packages;
- 只输出一个 windows_x64 包,未知平台被忽略;
- 包键精确为 platform/label/url/sha256/size/filename/type,额外字段被剔除;
- url/sha256/size/filename/type 与真实发布元数据逐字段一致;
- version=1.4.8,enabled=0 不阻断清单。
输出:Live public 1.4.8 metadata accepted with automatic checks disabled; top-level, package and platform whitelists passed.
实际接受的包:
platform=windows_x64
label=Windows 64位
filename=DoctorWorkstation-Setup-Windows-x64-1.4.8.exe
type=inno_setup
size=171474334
sha256=792f2273bba9f1d92d3b9e8eacabedcc53755ac003c284d648a9dca105bfb321
url=https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/desktop_package/1/20261017/1792229692103-0288326cfce9f6dc.exe
对上述真实 URL 仅发 HEAD:
HTTP 200
Content-Type=application/x-msdownload
Content-Length=171474334(与清单一致)
Last-Modified=Sat, 17 Oct 2026 09:34:52 GMT
证明现有线上地址可读取且大小吻合;未重新下载远程整包或进行安装验收。
验证边界
新 /adminapi/setting.desktop_workstation/downloads 是当前工作树实现;未声称该新接口已部署到生产。公开访问范围依据源码及指定纯测试核对,未启动完整框架/数据库进行本地 HTTP 集成请求。真实线上元数据来自已有 check 接口,不依赖新端点部署。本文件不包含前端分阶段提醒视觉或交互验收结果。
@@ -0,0 +1,65 @@
医生网页面诊迁移:前端类型检查基线验证
验证日期:2026-10-20
结论:最终当前代码和本任务相关的 HEAD 内存覆盖基线均返回 exit 2,各有 61 个 TypeScript 错误。按 git diff 的行号偏移映射后,所有诊断的文件、行号、列号、错误码、消息及续行说明完全一致:新增诊断 0,消失诊断 0。本任务未增加类型检查错误;全项目类型检查仍因既有问题失败,不能称为全量类型检查通过。
这是真实执行的 vue-tsc 基线对比,不仅是源码核对。原 .codex-tasks/doctor-web-migration-typecheck.log 为较早的 57 条诊断,未把该旧日志作为最终当前状态或最终基线。
运行方式
Node 包装 admin/node_modules/vue-tsc,通过安装版本的 _tsc.js 启动前钩子,在内存中覆盖 TypeScript 的 sys.readFile / sys.readDirectory / sys.fileExists。两个模式使用完全相同的选项:
--noEmit --composite false --incremental false --pretty false
HEAD 引用:d61bb3e117d538219aca01debd1fa2ce2e10a104
vue-tsc:2.2.2
TypeScript:5.7.3
current 模式读取当前工作区。
head 模式只把本任务改动的以下 6 个源文件读取替换为 git show HEAD:<path> 内容;工作区其他文件和依赖保持当前状态,因此这是本任务相关基线,不是完整 HEAD checkout:
admin/src/components/chat-dialog/index.vue
admin/src/stores/modules/user.ts
admin/src/views/patient/reception/index.vue
admin/src/views/tcm/diagnosis/index.vue
admin/src/views/tcm/appointment/list.vue
admin/src/views/tcm/appointment/list_h5.vue
head 模式同时从编译器目录枚举和文件存在判断中排除新增的 3 个源文件:
admin/src/components/doctor-web-consultation-notice/index.vue
admin/src/hooks/useDoctorWebConsultation.ts
admin/src/utils/doctor-web-consultation.ts
忽略的生成文件 admin/components.d.ts 在 Vite 构建时已加入新公告组件的全局声明。head 模式仅在内存中移除 DoctorWebConsultationNotice 这一条新增注册,防止该生成声明重新导入已排除的新组件;磁盘文件完全不改。
覆盖可靠性证据
最终 head 元数据确认 6 个 HEAD 源文件全部被编译器实际读取,每个至少 1 次;3 个新增文件全部从 readDirectory 中排除;生成声明的内存替换亦被读取。current 和 head 两轮 sourcePatched/sysPatched 都为 true。
最终两轮对 6 个改动源文件、3 个新增源文件、components.d.ts 的运行前后 SHA-256 均相同:unchanged=true,changedDuringRun=[]。sys.writeFile 被设置为失败保护,编译器若尝试写入会直接报错。没有实际回退工作区,没有创建 worktree,没有修改业务源文件,也没有读取 .env。
开始阶段前两轮在主线程继续补页面、Vite 重生成忽略的组件声明时检测到内容哈希变化,因此以 exit 97 拒收,没有冒称稳定基线成功。最终使用文件稳定后的第三轮结果;未继续重复运行或修复任何无关类型错误。忽略的生成声明确曾重生成,不能只根据 git status/diff 判断此类忽略文件未变。
诊断比较
最终全部 error TS 行数:current=61,head=61;比较脚本解析数量也分别为 61,未漏掉非 src 前缀诊断。
修改文件中的唯一当前诊断:
src/views/tcm/appointment/list.vue(91,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
Type 'unknown' is not assignable to type 'TreeNodeData'.
它映射到 HEAD 的同文件第 90 行,不在本次添加的公告组件或 import 行内。其余 60 条位于未改动的文件。新增 notice/hook/helper 均没有直接类型诊断。较早 57 条日志中的 diagnosis/index.vue 的 cmd 隐式 any 诊断不在最终 61 条输出中,因此不把旧日志行号直接套用到最终结果。
可复查文件
.codex-tasks/doctor-web-migration-typecheck-overlay.cjs
.codex-tasks/doctor-web-migration-compare-typecheck.cjs
.codex-tasks/doctor-web-migration-typecheck-current-verified.log
.codex-tasks/doctor-web-migration-typecheck-head-overlay.log
.codex-tasks/doctor-web-migration-typecheck-current-meta.json
.codex-tasks/doctor-web-migration-typecheck-head-meta.json
.codex-tasks/doctor-web-migration-typecheck-comparison.json
比较命令:node .codex-tasks/doctor-web-migration-compare-typecheck.cjs
最终比较退出 0,equivalent=true,sourceHashesUnchanged=true。
+117
View File
@@ -0,0 +1,117 @@
# 聊天记录同步缓存权限审计
日期:2026-10-20。范围:本地源码、项目部署说明和调度配置;未连接线上服务器,未执行修复或业务同步。排除了 `public`、`vendor`、`node_modules`、`runtime`;本报告不包含环境变量值或凭据。`.trellis/` 当前不存在;已读取根 `AGENTS.md`。
## 结论
`server/runtime/cache/la` 下 `file_put_contents(...): Permission denied` 与本项目默认文件缓存配置一致。聊天同步在 HTTP 和 CLI 中都会写同一套 Cache 检查点;调度器直接在当前进程调用业务命令,不切换身份。**若线上系统 cron / 宝塔任务以 root 执行 `php think crontab`,而 PHP-FPM worker 为 www,root 创建的缓存目录或文件会令后续 www 写入失败。** 这是源码能够支持的主要假设;必须结合线上文件属主、权限、ACL、实际调度用户完成确认,不能仅凭报错断言 root 是唯一原因。
持久修复应把同一应用的 PHP-FPM、系统 cron、常驻 worker、部署中的 PHP 命令统一为已确认的应用用户,再修复已有缓存归属。只执行一次 `chown` 而继续让 root 定时写缓存会复发。该故障通常无需修改聊天同步业务代码。
主线程现场反馈(本子任务未独立连接服务器复核):PHP-FPM 用户确认为 www;`runtime/cache/la/06`、`runtime/cache/la/09` 均为 root:root、0755;root 的宝塔 cron 同时存在 `php think crontab`、`sync_im_chat_archive`。这组证据已确认 root 调度与 www Web 进程共写文件缓存导致的权限冲突。主线程负责备份调度脚本、统一 PHP 执行用户和修复缓存归属。
## 证据与文件位置
| 文件和行号 | 观察 | 含义 |
|---|---|---|
| `server/config/cache.php:9` | `default` 为 `env('cache.driver', 'file')` | 未覆盖配置时采用文件缓存;实际驱动仍需线上确认。 |
| `server/config/cache.php:17`、`:19` | 文件缓存 `path` 为空、`prefix` 为 `la` | 符合报错中的 `runtime/cache/la` 路径。未读取 vendor,因此不将具体散列目录规则当作已验证事实。 |
| `server/app/adminapi/controller/tcm/DiagnosisController.php:491`、`:498`、`:499` | 调用分页同步,捕获 Throwable 后返回“聊天记录同步失败:”加原异常 | PHP 缓存权限异常会原样成为用户看到的同步失败消息。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php:1094`、`:1100` | 读写 `im_chat_sync:<token>`,TTL 3600 秒 | HTTP 每一步同步都依赖缓存写入。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php:1133`、`:1159` | 读写 `im_chat_complete_v1:<patient>:<account>`,TTL 86400 秒 | HTTP 和 CLI 共用会话完成检查点;不是单纯网页独有的缓存。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php:1175` | CLI 也调用 `advanceImChatSync()` | CLI 能以不同 OS 用户创建上述缓存目录和文件。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php:1192`、`:1200`、`:1209` | 批量补拉读写 `im_chat_archive_batch_cursor:<sinceDays>`,永久缓存 | 定时批处理还有额外文件缓存写入口。 |
| `server/app/common/command/SyncImChatArchive.php:21`、`:42` | 注册 `sync_im_chat_archive`,调用批量同步 | 这是具体定时补拉入口。 |
| `server/app/common/command/SyncImChatArchive.php:45`–`:50` | 记录错误并抛 RuntimeException | 归档失败会暴露为任务错误,不是成功返回。 |
| `server/app/common/command/Crontab.php:75`、`:77` | 通过 `Console::call()` 在当前进程执行命令 | 任务继承调度器的 OS 用户;数据库任务定义不负责切换 www。 |
| `server/app/common/command/Crontab.php:81`–`:86` | IM 归档失败后继续保持 START | 权限未修复时会反复失败;其他任务可能进入 ERROR。 |
| `server/config/console.php:23` | 命令类注册 | CLI 名称与业务入口相符。 |
| `server/sql/1.9.20260507/add_sync_im_chat_archive_crontab.sql:14`、`:17` | 注册 `sync_im_chat_archive`,周期 `*/30 * * * *` | 数据库内置调度与系统每分钟调度器是两层。 |
| `server/sql/1.9.20260909/fix_im_chat_archive_crontab.sql:3`–`:8` | 已有任务改为 `--since-days=0 --limit=200` | 调整覆盖范围不会解决文件权限;不要为本故障重复插入任务。 |
| `docs/plans/prescription-ai-deployment.md:19`、`:20`、`:28` | 既有部署记录为 `/www/wwwroot/zyt/server`、`/www/server/php/82/bin/php`、运行用户 www,曾验证 www 的 runtime 写权限 | 可作为现场核对起点;历史记录不能代替当前进程/权限检查。 |
| `docs/plans/assistant-revisit-reach-deployment.md:53`、`:60`–`:67` | 文档要求执行 `php think clear`、初始化命令和每分钟 `php think crontab`,没有在每条命令明确运行用户 | 运维按 root 终端照抄存在身份混用风险;后续说明宜明确 www。 |
| `server/docs/qywx-im-deploy.md:53`–`:64` | 另一组系统 cron 示例直接执行 php think,未写用户 | 同项目其他 cron 也需要核查身份,否则依然可能污染共享缓存。 |
| `docs/wecom-promotion-automation-deployment.md:30`–`:34` | Supervisor 示例使用 `user=www-data` | 是通用示例,线上为 www 时应使用现场身份;避免用户命名混用。 |
| `docker/docker-compose.yml:38`、`:41`–`:52` | PHP 服务 `user` 被注释;欢迎语 CLI worker 没有显式 `user` | 属本地开发栈隐患;未检查镜像默认用户,不能据此推断线上 root。 |
## 线上只读核对命令
以下 Linux 命令仅作为执行建议,本次未执行。路径来自现有部署记录,现场先核对:
```sh
APP=/www/wwwroot/zyt/server
readlink -f "$APP"
ps -eo user,group,pid,ppid,comm | awk 'NR == 1 || $5 ~ /php/'
stat -c '%U:%G %a %n' "$APP/runtime" "$APP/runtime/cache" "$APP/runtime/cache/la"
namei -l "$APP/runtime/cache/la"
find "$APP/runtime/cache" -xdev -type d ! -user www -printf '%u:%g %m %p\n' | head -n 40
find "$APP/runtime/cache" -xdev -type f ! -user www -printf '%u:%g %m %p\n' | head -n 40
runuser -u www -- test -w "$APP/runtime/cache/la"
printf 'cache-prefix-write-check=%s\n' "$?"
getfacl -p "$APP/runtime/cache" "$APP/runtime/cache/la"
lsattr -d "$APP/runtime/cache" "$APP/runtime/cache/la"
findmnt -T "$APP/runtime/cache/la"
df -h "$APP/runtime/cache/la"
df -i "$APP/runtime/cache/la"
```
还应对报错中**完整文件及其散列子目录**执行 `stat` / `namei -l` / `getfacl`。仅检查 `la` 顶层可写并不足够:root 所有的子目录不可写会阻止新缓存创建,root 所有的 0644 文件会阻止既有缓存覆盖;祖先目录缺少遍历权限、ACL 或 SELinux 也能产生相同报错。
调度位置定位时仅打印文件名,避免把脚本中可能含有的凭据写入审计输出:
```sh
grep -RIlE 'think[[:space:]]+(crontab|sync_im_chat_archive)' /var/spool/cron /etc/cron.d /www/server/cron 2>/dev/null
```
随后本地核实匹配 cron 的任务归属、宝塔配置中的执行用户,以及包装脚本是否降权。对于启动后常驻的 PHP worker,核查 Supervisor `user` / systemd `User` / 容器 UID。PHP-FPM 的 master 为 root 可以正常存在;应以处理请求的 pool worker 身份为准。
若属主与 POSIX 权限均正常,再核查 ACL mask、只读挂载、immutable 标记、SELinux 拒绝日志。磁盘满或 inode 满一般会产生不同错误,但可同时排除。
## 持久修复建议
1. 先把会写这个应用缓存的系统调度和常驻进程统一为确认后的应用用户。若 PHP-FPM worker 确认为 www,可让 root/宝塔调度脚本显式 `runuser -u www`;不要只是给命令外层做目录授权。保留既有执行周期、命令参数、日志和互斥机制,修改现有条目,避免重复注册调度。
2. 用户切换后,修复已产生的缓存所有权。限制操作到核实后的应用缓存目录,保留缓存内容;不要为此清空聊天归档表或整个 runtime。
3. 后续部署执行 `php think clear`、迁移、初始化、预热等 PHP 命令也使用 www;Supervisor、systemd、容器中涉及该应用的 worker 同样明确身份。
4. 如确实需要多个 OS 用户共享文件缓存,才采用专用组、setgid 目录、默认 ACL 与一致 umask;单一应用用户方案更直接。仅设 setgid 而文件仍为 0644 不能让同组用户覆盖文件。
root / 宝塔包装脚本示例(PHP 路径、项目目录、应用用户确认后使用):
```sh
#!/bin/sh
set -eu
cd /www/wwwroot/zyt/server
exec /usr/sbin/runuser -u www -- /www/server/php/82/bin/php think crontab
```
`/etc/cron.d` 示例具有独立的用户字段;不要把该行直接放进用户 crontab,二者格式不同:
```cron
* * * * * www cd /www/wwwroot/zyt/server && /www/server/php/82/bin/php think crontab
```
只修复已确认缓存树的命令示例(有变更,需在调度身份修正后执行):
```sh
APP=/www/wwwroot/zyt/server
CACHE=$(readlink -f -- "$APP/runtime/cache")
test "$CACHE" = /www/wwwroot/zyt/server/runtime/cache || exit 1
find "$CACHE" -xdev -type d -exec chown www:www {} +
find "$CACHE" -xdev -type f -exec chown www:www {} +
find "$CACHE" -xdev -type d -exec chmod u+rwx {} +
find "$CACHE" -xdev -type f -exec chmod u+rw {} +
```
该示例不跟随内部符号链接;如发现缓存树中存在符号链接,应先解释其用途和实际目标。仅恢复属主读写,不对整个站点执行 `chmod 777`。`runtime` 下可能保存 IAM 事务、私有音频、加密密钥等有意设为 0600 的文件;不要笼统递归 chmod 全部 runtime。
若 `runtime/cache` 本身不存在,则在确认路径无重定向后以 www 身份创建所需目录;同时保证祖先目录可遍历。若错误路径实际为 `runtime/adminapi/cache` 等应用子路径,应按现场路径做等价的有限修复。
不建议为了本次事件直接全站改用 Redis:当前配置提供 Redis 选项,但切换会影响所有默认缓存、已有登录状态及正在续拉的 token,另需验证连接、数据语义和所有进程配置一致性。即使改 Redis,其他 runtime 文件仍要求正确的进程身份。
## 验收标准
- HTTP 同步至少推进一次 token 续拉,返回同步进度而无 Permission denied;检查点完成后可再次同步。
- 用 www 执行一个获授权诊单的补拉命令:`/www/server/php/82/bin/php think sync_im_chat_archive --diagnosis-id=<已确认ID> --limit=1`。该命令会请求腾讯并写业务归档,不是只读探针,避免作为权限调查的第一步。
- 等待现有系统调度至少完成一轮后,重新检查缓存所有权,确认没有新增 root 属主目录/文件;后台 IM 定时任务错误清除、last_time 正常推进。只在当前数据证明必要时恢复异常停掉的其他任务。
- 重复检查 Supervisor / systemd / cron / 手工部署 PHP 命令身份;仅手工 `chown` 后立即点击成功,尚不足以证明不会复发。
本子任务未修改调度、改动业务代码或执行线上验证命令;线上属主证据仅按主线程现场反馈记录。修复和验收结果由主线程补充。
@@ -0,0 +1,39 @@
{
"repair": {
"started_at": "2026-10-20T10:50:01+0800",
"jobs": 256,
"completed_jobs": 256,
"scanned": 2590008,
"changed": 933369,
"expired_during_scan": 2,
"errors": [],
"complete": true,
"finished_at": "2026-10-20T10:58:26+0800"
},
"metadata": {
"tasks_verified": 14,
"php_commands_verified": 17,
"cache_directory_owners": {
"www": 256
},
"sampled_cache_file_owners": {
"www": 256
},
"scheduler_users": [
"www"
]
},
"functional": {
"uid": 1000,
"default_cache_driver": "think\\cache\\driver\\File",
"cache_buckets_passed": 256,
"sync_completed": true,
"sync_steps": 3,
"sync_errors": [],
"diagnosis_id": 13780,
"doctor_id": 213,
"archived_rows": 27,
"sync_error": ""
},
"verified_at": "2026-10-20T10:59:33.829357+08:00"
}
@@ -0,0 +1 @@
/mysql-runtime/
@@ -0,0 +1,68 @@
Payment -> prescription business order links: backend verification
Date: 2026-10-22
Implementation
- Both admin payment list rows and detail return prescription_orders: [{id: int, order_no: string}], or [].
- Shared lookup: server/app/adminapi/service/order/PaymentPrescriptionOrderService.php.
- Lookup runs after the existing permission/search/data scope/pagination query; count() is unchanged.
- Exactly one additional joined SELECT for a nonempty payment page; no lookup for an empty page.
- Query is bounded to IDs of payments on that page; prescription orders are joined by the actual junction.
- Result is deduplicated by prescription business order ID, ordered by business order ID, and projects only id/order_no.
- Soft-deleted prescription business orders and dangling junction targets are excluded.
- Cancelled/refunded business orders and payments retain display links while the junction still exists.
- Existing Order model soft-delete scopes keep deleted payments absent from both list and detail.
- No payment mutation, permission change, count/pagination change, migration, or business-data connection was introduced.
Legacy association decision
- tcm_prescription_order_pay_order is the canonical relationship used by existing business order readers/mutations.
- Existing migration 2026_04_09_prescription_order_pay_links.sql already imports valid linked_pay_order_id rows idempotently.
- No runtime fallback to linked_pay_order_id: it can be stale, including after the existing payment split path.
- Current unlink/clear mutations clear the primary legacy field, but an old/stale value must not resurrect a removed junction.
- Regression covers positive migrated legacy history, a legacy-only stale field, explicit unlink then stale primary, and a real split transfer.
- A deployment must already have the existing April association-table migration applied, as other existing order features require.
Isolation and cleanup
- PHP 8.2.9 with project's installed ThinkPHP/ThinkORM vendor runtime.
- MySQL 5.7.26 started with --no-defaults on 127.0.0.1:13429 using a freshly initialized disposable data directory.
- Application initialize(), .env, production database configuration, and server/config/project.php were not loaded/read.
- Tests create random named databases and DROP only those databases in finally blocks.
- Database process 19164 was shut down with mysqladmin; subsequent checks found no process or listening port 13429.
- Automatic approval review rejected recursive deletion of the verified exact directory
D:\web\zyt\artifacts\payment-prescription-links\mysql-runtime
with stated reason "blocked by policy". No additional reason was supplied. Temporary runtime files remain there.
- Artifact-local .gitignore excludes mysql-runtime/ to prevent accidental staging. No approval bypass was attempted.
- Temporary instrumented regression test copies were removed.
Passed checks
1. ZYT_PAYMENT_LINK_TEST_MYSQL_PORT=13429 php tests/PaymentPrescriptionOrderLinksTest.php
PASS (52 checks), executing real OrderLists::lists/count, OrderLogic::detail,
PrescriptionOrderLogic::unlinkPayOrder and OrderLogic::split with the actual ORM.
Coverage: multiple payment rows, one/multiple/no associations, duplicate rows,
same patient without a relation, deleted/dangling business orders, deleted payments,
status/search/creator permission, page order/offset/count, one query restricted to page IDs,
typed/minimal payload, list/detail consistency, empty page no-query, migrated legacy history,
stale primary/unlink behavior, cancelled/refunded history, and split transfer to every child.
2. ZYT_UNLINK_TEST_MYSQL_PORT=13429 php tests/PrescriptionOrderUnlinkPayOrderTest.php
PASS (131 assertions), original unmodified test.
3. php tests/OrderEditTimeContractTest.php
PASS (Order edit time contract: OK), original unmodified test.
4. PHP lint: OrderLists.php, OrderLogic.php, PaymentPrescriptionOrderService.php,
PaymentPrescriptionOrderLinksTest.php: all PASS.
5. Scoped git diff --check: PASS after parent mechanically normalized only added/changed
OrderLogic.php lines to LF, preserving the existing unchanged CRLF lines.
PHP lint for OrderLogic.php also passed after that line-ending correction.
Existing test bootstrap limitation and supplementary validation
- Original PrescriptionOrderReleaseAndTimeTest.php and PrescriptionOrderTrackingAuditTest.php
initially failed in RevisitDueHook's warning path: Unable to resolve NULL driver for [think\Log].
Their isolated empty config has no logger; the revisit hook hits fixture schema it does not define.
- Re-ran temporary copies in the same tests directory with exactly one additional bootstrap statement,
immediately after the existing empty config binding:
Container::getInstance()->instance('log', new \Psr\Log\NullLogger());
- No production method/schema/assertion was modified, and original tests remain unchanged.
- ReleaseAndTime: PASS (112 assertions); TrackingAudit: PASS (196 assertions).
- These two supplementary passes validate their original order behavior but do not validate revisit hooks.
Verification limit
- Testing used synthetic disposable fixtures, not production data or a deployed authenticated HTTP session.
- Existing frontend verification is recorded separately in frontend-verification.txt.
@@ -0,0 +1,18 @@
支付单关联处方业务订单号:前端验证
日期:2026-10-22
修改:admin/src/views/order/index.vue
- 支付单列表在支付单订单号旁增加“处方业务订单号”列。
- 支付单详情增加同名字段。
- 读取 prescription_orders 数组;多笔逐条展示,无关联展示“未关联”。
- 编号自动换行,Vue 插值转义;展示不发起逐行网络请求。
通过的检查:
- 使用 @vue/compiler-sfc 编译完整页面的 script setup 和 template,无编译错误。
- 从真实页面 AST 提取新增列表列及详情字段,以 @vue/server-renderer 渲染 8 个场景:
字段缺失、空关联、单笔关联、多笔关联分别在列表/详情展示。
- 验证无关联占位、实际业务订单号、支付单号不混淆及带 HTML 字符的编号转义。
- git diff --check -- admin/src/views/order/index.vue 通过。
本次为展示字段变更,未新增前端业务逻辑;未重复运行全量生产构建。
未部署、未修改真实支付/处方数据。
@@ -0,0 +1,50 @@
# IM 聊天同步缓存权限
线上应用路径为 `/www/wwwroot/zyt/server`,PHP 为 `/www/server/php/82/bin/php`,PHP-FPM 工作进程和应用 worker 使用 `www`。
## 故障原因
网页分页同步、CLI 聊天补拉、内置定时任务共用 `runtime/cache/la`。root 直接执行 `php think` 会生成 root 所有的 0755 子目录或 0644 文件,随后 www 的 `file_put_contents` 报 Permission denied。2026-10-20 现场确认 256 个散列子目录中有 76 个归 root 所有。
## 修复与防复发
先备份现有任务定义和生成脚本,把此应用中以 root 运行的 PHP 命令改为:
```sh
cd /www/wwwroot/zyt/server
/usr/sbin/runuser -u www -- /www/server/php/82/bin/php think crontab
```
`sync_im_chat_archive` 及其他同项目命令同样使用该前缀,保留原参数、周期和 flock。宝塔计划任务必须同时修改保存的脚本内容与当前生成的 `/www/server/cron/<任务标识>`;只修改生成脚本会在面板重新保存时丢失修复。已有 www 包装的任务不需要重复降权。
部署、迁移、预热、清理等手动 PHP 命令也必须使用 www。Supervisor 的本应用 worker 同样设置 `user=www`。
确认路径没有重定向后,仅修复已有缓存树的 root 属主,保留缓存内容和权限:
```sh
cache_path=/www/wwwroot/zyt/server/runtime/cache
test "$(readlink -f -- "$cache_path")" = "$cache_path" || exit 1
chown -hR -P --from=root www:www "$cache_path"
```
不清空缓存或聊天归档,不对整个 runtime 递归 chmod;其中包含私有文件。
## 验证
1. 用 www 通过 ThinkPHP Cache 写入、读取、覆盖、删除专用探针键,确保遍历全部 256 个散列目录。
2. 在诊单 13780 的聊天记录中完成一次分页同步,并再次触发同步验证检查点可覆盖。
3. 等待原有调度至少运行一轮,检查 PHP 任务身份与探针、检查点文件属主,确认没有重新产生 root 缓存。
4. 检查宝塔保存的任务正文与生成脚本均已更新;使用备份文件可恢复原始任务定义。
本次是线上运行用户和文件归属修复,无需更改聊天同步业务代码。
## 2026-10-20 现场执行记录
- 原任务备份:服务器 `/root/zyt-cache-permissions-20261020T104145/`,包含任务原正文、生成脚本及 root crontab。
- 已修正 14 条宝塔任务、17 条 PHP 命令,任务正文与生成脚本逐项比对一致;已有 www 包装的任务保持原样。
- 已停止仍在运行的旧 root 调度进程,原有 cron 自动启动的新调度进程为 www;分钟通知任务连续正常完成。
- www 通过实际 ThinkPHP File 驱动遍历 256 个散列分区,创建、读取、覆盖、删除及文件属主检查全部通过。
- 诊单 13780 的全账号分页流程连续推进 66 步、完成 32 个会话,未出现权限错误;随后停止该诊断流程,改为对截图中关联账号 213 的会话完成两轮完整同步。两轮均无错误,归档读取返回 27 条记录;旧记录没有重复插入。
- 同步验证在服务器以 www 调用网页使用的 `syncImChatArchiveStep()` 和归档读取逻辑完成。所给 admin1 不是业务后台账号,未进行已登录网页接口复测。
- 存量修复于 2026-10-20 10:58(北京时间)全部完成:256 个分区,共检查 2,590,008 项缓存文件与目录,错误数为 0;扫描期间 2 项缓存由正常业务过期移除。修复保留缓存内容和原权限,仅恢复 root 所有条目的归属。
- 最终复核通过:14 条任务、17 条 PHP 命令与持久化配置一致;256 个目录和每目录抽样文件均为 www;调度进程为 www;256 个缓存分区读写、覆盖、删除通过,诊单 13780 / 关联账号 213 的同步在 3 步内完成且无错误,归档仍为 27 条。结构化证据见 [验收记录](../../artifacts/im-chat-cache-permission-verification.json)。
@@ -43,6 +43,8 @@ stderr_logfile=/var/log/qywx-promotion-welcome-error.log
迁移会把每分钟补偿和每5分钟素材预热登记到 `zyt_dev_crontab`;部署仍须保证 `php think crontab` 被系统每分钟触发。若部署不使用内置任务表,可改用以下系统 cron:
这些 cron 示例应安装在 PHP-FPM 应用用户的 crontab 中。当前生产用户为 `www`(Supervisor 示例的 `www-data` 也应相应替换);root / 宝塔任务执行 PHP 时须加 `/usr/sbin/runuser -u www -- /www/server/php/82/bin/php` 前缀,避免共享缓存变为 root 所有。具体修复和验证见 [缓存权限说明](../deployment/im-chat-cache-permissions/README.md)。
```cron
* * * * * cd /path/to/server && /usr/bin/flock -n /tmp/qywx-promotion-retry.lock /usr/bin/php think qywx:retry-promotion-automation
*/5 * * * * cd /path/to/server && /usr/bin/flock -n /tmp/qywx-promotion-media.lock /usr/bin/php think qywx:refresh-promotion-media
+2
View File
@@ -20,6 +20,8 @@ return [
app\adminapi\http\middleware\LoginMiddleware::class,
// 权限认证
app\adminapi\http\middleware\AuthMiddleware::class,
// 医生网页面诊到期限制(须在登录及权限校验之后执行)
app\adminapi\http\middleware\DoctorWebConsultationMiddleware::class,
// 演示模式 - 禁止提交数据
app\adminapi\http\middleware\CheckDemoMiddleware::class,
// 演示模式 - 不返回敏感数据
@@ -16,6 +16,7 @@ namespace app\adminapi\controller;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\ConfigLogic;
use app\adminapi\service\DoctorWebConsultationPolicy;
/**
* 配置控制器
@@ -24,7 +25,13 @@ use app\adminapi\logic\ConfigLogic;
*/
class ConfigController extends BaseAdminController
{
public array $notNeedLogin = ['getConfig', 'dict'];
public array $notNeedLogin = ['getConfig', 'dict', 'doctorWebConsultation'];
/** 医生网页面诊迁移时间,只读且免登录,供已打开的网页同步配置。 */
public function doctorWebConsultation()
{
return $this->data(DoctorWebConsultationPolicy::publicPolicy());
}
/**
@@ -58,4 +65,4 @@ class ConfigController extends BaseAdminController
}
}
@@ -25,7 +25,13 @@ use app\adminapi\validate\setting\DesktopWorkstationValidate;
*/
class DesktopWorkstationController extends BaseAdminController
{
public array $notNeedLogin = ['check'];
public array $notNeedLogin = ['check', 'downloads'];
/** @notes 提供已发布的各平台安装包,医生无需系统设置权限即可下载 */
public function downloads()
{
return $this->data(DesktopWorkstationLogic::downloads());
}
/**
* @notes 读取升级策略与安装包
@@ -429,13 +429,17 @@ class DiagnosisController extends BaseAdminController
*/
public function getCallRecords()
{
$params = $this->request->get();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
$rawDiagnosisId = $this->request->get('diagnosis_id', 0);
$diagnosisId = is_int($rawDiagnosisId) || is_string($rawDiagnosisId)
? filter_var($rawDiagnosisId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]])
: false;
if ($diagnosisId === false) {
return $this->fail('诊单ID无效');
}
$result = DiagnosisLogic::getCallRecords($params);
if (!DiagnosisLogic::canViewReadonlyDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$result = DiagnosisLogic::getCallRecords(['diagnosis_id' => $diagnosisId]);
return $this->data($result);
}
@@ -80,6 +80,16 @@ class AuthMiddleware
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 面诊回放与文字共用读取权限;未登记该接口时也不能走历史放行分支。
if ($accessUri === 'tcm.diagnosis/getcallrecords') {
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array('tcm.diagnosis/huifang', $uris, true)
|| in_array('tcm.diagnosis/getcallrecords', $uris, true)) {
return $next($request); // 控制器还会校验当前诊单的数据域。
}
return JsonService::fail('权限不足,无法查看面诊记录');
}
// This group must never use the legacy unregistered-route bypass.
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\service\DoctorWebConsultationPolicy;
use app\common\service\JsonService;
final class DoctorWebConsultationMiddleware
{
public function __construct(private ?array $configuration = null)
{
}
public function handle($request, \Closure $next)
{
if (DoctorWebConsultationPolicy::blocksRequest(
(array) ($request->adminInfo ?? []),
(string) $request->controller(),
(string) $request->action(),
(array) $request->header(),
null,
$this->configuration
)) {
return JsonService::fail(DoctorWebConsultationPolicy::message($this->configuration), [
'reason' => 'doctor_web_consultation_expired',
'cutoff_at' => DoctorWebConsultationPolicy::cutoffAt($this->configuration),
]);
}
return $next($request);
}
}
+10 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\lists\order;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\service\order\PaymentPrescriptionOrderService;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\Order;
@@ -218,12 +219,20 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
$query = Order::where($where);
$this->applyDataScopeByOwner($query, 'creator_id');
return $query
$rows = $query
->with(['patient', 'creator'])
->order(['create_time' => 'desc'])
->limit($this->limitOffset, $this->pageSize)
->select()
->toArray();
$prescriptionOrders = PaymentPrescriptionOrderService::forPaymentIds(array_column($rows, 'id'));
foreach ($rows as &$row) {
$row['prescription_orders'] = $prescriptionOrders[(int) $row['id']] ?? [];
}
unset($row);
return $rows;
}
/**
@@ -15,6 +15,7 @@
namespace app\adminapi\logic\auth;
use app\adminapi\logic\LoginLogic;
use app\adminapi\service\DoctorWebConsultationPolicy;
use app\common\cache\AdminAuthCache;
use app\common\enum\YesNoEnum;
use app\common\logic\BaseLogic;
@@ -318,6 +319,7 @@ class AdminLogic extends BaseLogic
$authRoleIds = AdminRole::where('admin_id', $params['id'])->column('role_id');
$admin['role_ids'] = array_values(array_map('intval', $authRoleIds));
$admin['web_consultation_policy'] = DoctorWebConsultationPolicy::publicPolicy();
$admin['need_bind_work_wechat'] = LoginLogic::adminMustBindWorkWechat([
'root' => (int) ($admin['root'] ?? 0),
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\order;
use app\adminapi\service\order\PaymentPrescriptionOrderService;
use app\common\model\Order;
use app\common\model\OrderDetail;
use app\common\model\auth\Admin;
@@ -776,7 +777,11 @@ class OrderLogic
return null;
}
return $order->toArray();
$detail = $order->toArray();
$prescriptionOrders = PaymentPrescriptionOrderService::forPaymentIds([$id]);
$detail['prescription_orders'] = $prescriptionOrders[$id] ?? [];
return $detail;
}
private static $error = '';
@@ -37,6 +37,36 @@ class DesktopWorkstationLogic extends BaseLogic
public static function getConfig(): array
{
return self::present(self::loadStored());
}
/** 医生公告使用的安装包清单,与自动升级检测开关独立。 */
public static function downloads(): array
{
return self::downloadCatalog(self::getConfig());
}
public static function downloadCatalog(array $config): array
{
$labels = [
'windows_x64' => 'Windows 64位',
'macos_arm64' => 'macOS(Apple芯片)',
'macos_x64' => 'macOS(Intel芯片)',
];
$packages = [];
foreach (self::PLATFORMS as $platform) {
$package = self::plainPackage((array) ($config['packages'][$platform] ?? []), $platform);
$url = parse_url($package['url']);
if (!is_array($url) || !in_array(strtolower($url['scheme'] ?? ''), ['https', 'http'], true)
|| empty($url['host']) || isset($url['user']) || isset($url['pass'])
|| !preg_match('/^[a-f0-9]{64}$/', $package['sha256'])) {
continue;
}
$packages[] = ['platform' => $platform, 'label' => $labels[$platform]] + $package;
}
return [
'version' => self::normalizeVersion((string) ($config['latest_version'] ?? '')),
'packages' => $packages,
];
}
/**
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/** 医生网页面诊迁移。客户端识别用于迁移兼容,原有登录和数据权限仍须独立校验。 */
final class DoctorWebConsultationPolicy
{
public static function cutoffTimestamp(?array $configuration = null): int
{
return self::schedule($configuration)['cutoff']->getTimestamp();
}
public static function cutoffAt(?array $configuration = null): string
{
return self::schedule($configuration)['cutoff']->format(DATE_ATOM);
}
public static function message(?array $configuration = null): string
{
return '网页端面诊已于' . self::schedule($configuration)['cutoff']->format('Y年n月j日H:i:s')
. '(北京时间)停用,请医生使用桌面应用进行面诊。';
}
public static function publicPolicy(?int $now = null, ?array $configuration = null): array
{
$schedule = self::schedule($configuration);
return [
'cutoff_at' => $schedule['cutoff']->format(DATE_ATOM),
'approaching_at' => $schedule['approaching']->format(DATE_ATOM),
'urgent_at' => $schedule['urgent']->format(DATE_ATOM),
'server_time' => $now ?? time(),
];
}
public static function blocksRequest(
array $adminInfo,
string $controller,
string $action,
array $headers,
?int $now = null,
?array $configuration = null
): bool {
if (strtolower($controller) !== 'tcm.diagnosis'
|| !in_array(strtolower($action), [
'getcallsignature', 'startcall', 'getdoctorsignature', 'getpatientsignature',
], true)
|| (int) ($adminInfo['root'] ?? 0) === 1) {
return false;
}
// 来自登录令牌缓存的角色,不能取请求参数;非 root 且含角色 1 即医生。
$roles = array_map('intval', (array) ($adminInfo['role_id'] ?? []));
if (!in_array(1, $roles, true)) {
return false;
}
$headers = array_change_key_case($headers, CASE_LOWER);
// 浏览器信号优先,防止浏览器误携带桌面标识后被放行。
foreach (['origin', 'referer', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-dest'] as $name) {
if (trim((string) ($headers[$name] ?? '')) !== '') {
return ($now ?? time()) >= self::cutoffTimestamp($configuration);
}
}
// 两端历史上都使用 terminal=1,不能据终端编号误停旧版医生工作站。
// 新版工作站使用明确 UA,旧版保留 HTTPX 默认 UA 的兼容性。
$userAgent = trim((string) ($headers['user-agent'] ?? ''));
if (preg_match('/^(?:ZytDoctorWorkstation|python-httpx)\/\d+(?:\.\d+){1,3}$/i', $userAgent) === 1) {
return false;
}
return ($now ?? time()) >= self::cutoffTimestamp($configuration);
}
/** 每次读取固定配置文件,绕过 ThinkPHP 配置缓存及长驻进程中的静态日期缓存。 */
private static function configuration(): array
{
$path = dirname(__DIR__, 3) . '/config/doctor_web_consultation.php';
if (!is_file($path) || !is_readable($path)) {
throw new \UnexpectedValueException('doctor_web_consultation 配置文件不存在或不可读');
}
// 即使 PHP 设置了 opcache.validate_timestamps=0,也让本次读取使用新内容。
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, true);
}
$configuration = require $path;
if (!is_array($configuration)) {
throw new \UnexpectedValueException('doctor_web_consultation 配置必须返回数组');
}
return $configuration;
}
/** @return array<string, \DateTimeImmutable> */
private static function schedule(?array $configuration): array
{
$configuration = $configuration ?? self::configuration();
$value = $configuration['cutoff_at'] ?? null;
$timezone = new \DateTimeZone('Asia/Shanghai');
$cutoff = is_string($value) && preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\z/', $value) === 1
? \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, $timezone)
: false;
$errors = \DateTimeImmutable::getLastErrors();
if ($cutoff === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))
|| $cutoff->format('Y-m-d H:i:s') !== $value || (int) $cutoff->format('Y') < 1) {
throw new \InvalidArgumentException('doctor_web_consultation.cutoff_at 必须是有效北京时间,格式为 YYYY-MM-DD HH:MM:SS');
}
$hours = [];
foreach (['approaching_hours', 'urgent_hours'] as $key) {
$hours[$key] = $configuration[$key] ?? null;
if (!is_int($hours[$key]) || $hours[$key] < 0 || $hours[$key] > intdiv(PHP_INT_MAX, 3600)) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $key . ' 必须是有效非负整数小时数');
}
}
if ($hours['approaching_hours'] < $hours['urgent_hours']) {
throw new \InvalidArgumentException('doctor_web_consultation.approaching_hours 必须大于或等于 urgent_hours');
}
$schedule = ['cutoff' => $cutoff];
foreach (['approaching', 'urgent'] as $phase) {
$seconds = $hours[$phase . '_hours'] * 3600;
if ($cutoff->getTimestamp() < PHP_INT_MIN + $seconds) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $phase . '_hours 超出有效日期范围');
}
$date = $cutoff->setTimestamp($cutoff->getTimestamp() - $seconds);
if ((int) $date->format('Y') < 1) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $phase . '_hours 超出有效日期范围');
}
$schedule[$phase] = $date;
}
return $schedule;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\order;
use think\facade\Db;
/**
* 支付单反查处方业务订单。调用方先按原有权限和分页取得支付单,再批量补充关联。
*/
class PaymentPrescriptionOrderService
{
/**
* @param int[] $paymentIds
* @return array<int, list<array{id: int, order_no: string}>>
*/
public static function forPaymentIds(array $paymentIds): array
{
$paymentIds = array_values(array_unique(array_filter(
array_map('intval', $paymentIds),
static fn (int $id): bool => $id > 0
)));
if ($paymentIds === []) {
return [];
}
$result = array_fill_keys($paymentIds, []);
// 历史 linked_pay_order_id 已由迁移写入关联表;不能用旧字段恢复已移除或已拆分的关联。
// 不按履约/支付状态筛选,保留仍有关联的取消、退款订单历史。
$rows = Db::name('tcm_prescription_order_pay_order')->alias('link')
->join('tcm_prescription_order po', 'po.id = link.prescription_order_id')
->whereIn('link.pay_order_id', $paymentIds)
->whereNull('po.delete_time')
->field(['link.pay_order_id', 'po.id', 'po.order_no'])
->order('po.id', 'asc')
->select()
->toArray();
foreach ($rows as $row) {
$result[(int) $row['pay_order_id']][(int) $row['id']] = [
'id' => (int) $row['id'],
'order_no' => (string) $row['order_no'],
];
}
foreach ($result as &$orders) {
$orders = array_values($orders);
}
unset($orders);
return $result;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* 医生网页面诊迁移时间(北京时间 Asia/Shanghai)。
*
* cutoff_at 可改为任意有效日期和时分秒,必须完整填写 YYYY-MM-DD HH:MM:SS;
* 例如 2027-02-18 14:30:45。不接受时区后缀、相对日期或自动补齐日期。
* 两级提醒会自动跟随截止时间,不必另改日期。小时数须为非负整数,
* approaching_hours 必须大于或等于 urgent_hours;设为 0 表示截止时才进入该级。
* 修改本文件后下一次接口请求生效,无须重新打包前端或清理框架配置缓存。
*/
return [
'cutoff_at' => '2026-10-25 00:00:00',
'approaching_hours' => 48,
'urgent_hours' => 24,
];
+2
View File
@@ -52,6 +52,8 @@ mysql -u<user> -p<pass> <db> < server/database/migrations/2026_04_20_create_qywx
Linux crontab(每 30s 跑一次的 2 行写法):
以下示例安装在 PHP-FPM 应用用户的 crontab 中。当前生产用户为 `www`;若由 root / 宝塔调度,PHP 命令应使用 `/usr/sbin/runuser -u www -- /www/server/php/82/bin/php` 前缀。CLI、常驻 worker 和网页共用文件缓存,混用 root 会导致网页同步报 Permission denied。已有缓存修复和宝塔任务持久化要求见 [缓存权限说明](../../deployment/im-chat-cache-permissions/README.md)。
```
* * * * * cd /path/to/server && /usr/bin/php think qywx:sync-msg-archive --download >/dev/null 2>&1
* * * * * cd /path/to/server && sleep 30 && /usr/bin/php think qywx:sync-msg-archive --download >/dev/null 2>&1
@@ -1 +1 @@
import t from"./error-DD91xBpX.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CyuAWpdG.js";import"./index-Bkarz3gO.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
import t from"./error-DMQv7vSg.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CyuAWpdG.js";import"./index-Bv3nGWNs.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
@@ -1 +1 @@
import e from"./error-DD91xBpX.js";import{o,q as r,r as t,v as s}from"./.pnpm-CyuAWpdG.js";import"./index-Bkarz3gO.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
import e from"./error-DMQv7vSg.js";import{o,q as r,r as t,v as s}from"./.pnpm-CyuAWpdG.js";import"./index-Bv3nGWNs.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
@@ -1 +1 @@
import{o as C,R as D,q as d,r as I,ac as N,O as u,bk as B,D as o,v as l,bl as E,bm as L,L as i,T as r,s as p,bn as R,M as w}from"./.pnpm-CyuAWpdG.js";import{a as V}from"./doctor-DBvYeWd3.js";import{n as A,_ as M}from"./index-Bkarz3gO.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},q={class:"font-medium"},O={class:"text-gray-500 text-sm"},j={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",q,r(e.patient_name||"—"),1),p("span",O,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",j,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
import{o as C,R as D,q as d,r as I,ac as N,O as u,bk as B,D as o,v as l,bl as E,bm as L,L as i,T as r,s as p,bn as R,M as w}from"./.pnpm-CyuAWpdG.js";import{a as V}from"./doctor-zCREGA6g.js";import{n as A,_ as M}from"./index-Bv3nGWNs.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},q={class:"font-medium"},O={class:"text-gray-500 text-sm"},j={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",q,r(e.patient_name||"—"),1),p("span",O,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",j,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
@@ -1 +1 @@
import{o as $,R as D,q as c,r as u,ac as F,O as L,bk as E,D as o,v as s,bl as P,L as _,T as d,F as A,bm as B,P as V,s as v,a1 as M,w as q,u as N,cj as w,bn as K,M as x}from"./.pnpm-CyuAWpdG.js";import{ag as j}from"./tcm-BSADvQNg.js";import{_ as z}from"./index-Bkarz3gO.js";const H={class:"assign-log-panel"},O={key:0,class:"assign-log-inherit-slot"},R={key:1,class:"text-gray-400"},Y=$({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(y,{expose:S}){const m=y,p=x(!1),f=x([]);function T(a){const t=a.related_po_creator_name;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.related_po_creator_id);return Number.isFinite(e)&&e>0?`ID:${e}`:"—"}function k(a){const t=a.related_po_create_time_text;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.related_po_create_time);if(!Number.isFinite(e)||e<=0)return"—";const n=new Date(e*1e3);if(Number.isNaN(n.getTime()))return"—";const i=l=>String(l).padStart(2,"0");return`${n.getFullYear()}-${i(n.getMonth()+1)}-${i(n.getDate())} ${i(n.getHours())}:${i(n.getMinutes())}:${i(n.getSeconds())}`}function g(a){const t=a.inherit_visit_slot_text;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.inherit_visit_slot_calc_time);if(!Number.isFinite(e)||e<=0)return"";const n=Number(a.inherit_visit_slot);return Number.isFinite(n)&&n>0?`继承时第 ${n} 诊`:"继承时未成交"}function b(a,t){const e=t==="from"?"from_assistant_name":"to_assistant_name",n=t==="from"?"from_assistant_id":"to_assistant_id",i=a[e];if(i!=null&&String(i).trim()!==""&&String(i)!=="—")return String(i);const l=Number(a[n]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const h=async()=>{if(m.diagnosisId){p.value=!0;try{const a=await j({id:m.diagnosisId}),t=Array.isArray(a)?a:[];f.value=t}catch(a){console.error(a),f.value=[]}finally{p.value=!1}}};return D(()=>m.diagnosisId,()=>{h()},{immediate:!0}),S({refresh:h}),(a,t)=>{const e=P,n=q,i=M,l=B,C=E,I=K;return c(),u("div",H,[F((c(),L(C,{data:f.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:o(()=>[s(e,{label:"操作时间",width:"175",prop:"create_time_text"}),s(e,{label:"原医助","min-width":"120"},{default:o(({row:r})=>[_(d(b(r,"from")),1)]),_:1}),s(e,{label:"新医助","min-width":"120"},{default:o(({row:r})=>[_(d(b(r,"to")),1)]),_:1}),s(e,{label:"继承",width:"118",align:"center"},{header:o(()=>[t[0]||(t[0]=v("span",null,"继承",-1)),s(i,{placement:"top",content:"继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"},{default:o(()=>[s(n,{class:"assign-log-col-hint"},{default:o(()=>[s(N(w))]),_:1})]),_:1})]),default:o(({row:r})=>[Number(r.is_inherit)===1?(c(),u(A,{key:0},[s(l,{type:"success",size:"small"},{default:o(()=>[...t[1]||(t[1]=[_("是",-1)])]),_:1}),g(r)?(c(),u("div",O,d(g(r)),1)):V("",!0)],64)):(c(),u("span",R,"否"))]),_:1}),s(e,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:o(({row:r})=>[_(d(T(r)),1)]),_:1}),s(e,{label:"快照·业务单创建时间",width:"190"},{header:o(()=>[t[2]||(t[2]=v("span",null,"快照·业务单创建时间",-1)),s(i,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:o(()=>[s(n,{class:"assign-log-col-hint"},{default:o(()=>[s(N(w))]),_:1})]),_:1})]),default:o(({row:r})=>[_(d(k(r)),1)]),_:1}),s(e,{label:"操作人",width:"110",prop:"operator_name"}),s(e,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),s(e,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,p.value]])])}}}),U=z(Y,[["__scopeId","data-v-078f6683"]]);export{U as default};
import{o as $,R as D,q as c,r as u,ac as F,O as L,bk as E,D as o,v as s,bl as P,L as _,T as d,F as A,bm as B,P as V,s as v,a1 as M,w as q,u as N,cj as w,bn as K,M as x}from"./.pnpm-CyuAWpdG.js";import{ag as j}from"./tcm-CiKToMpm.js";import{_ as z}from"./index-Bv3nGWNs.js";const H={class:"assign-log-panel"},O={key:0,class:"assign-log-inherit-slot"},R={key:1,class:"text-gray-400"},Y=$({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(y,{expose:S}){const m=y,p=x(!1),f=x([]);function T(a){const t=a.related_po_creator_name;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.related_po_creator_id);return Number.isFinite(e)&&e>0?`ID:${e}`:"—"}function k(a){const t=a.related_po_create_time_text;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.related_po_create_time);if(!Number.isFinite(e)||e<=0)return"—";const n=new Date(e*1e3);if(Number.isNaN(n.getTime()))return"—";const i=l=>String(l).padStart(2,"0");return`${n.getFullYear()}-${i(n.getMonth()+1)}-${i(n.getDate())} ${i(n.getHours())}:${i(n.getMinutes())}:${i(n.getSeconds())}`}function g(a){const t=a.inherit_visit_slot_text;if(t!=null&&String(t).trim()!=="")return String(t);const e=Number(a.inherit_visit_slot_calc_time);if(!Number.isFinite(e)||e<=0)return"";const n=Number(a.inherit_visit_slot);return Number.isFinite(n)&&n>0?`继承时第 ${n} 诊`:"继承时未成交"}function b(a,t){const e=t==="from"?"from_assistant_name":"to_assistant_name",n=t==="from"?"from_assistant_id":"to_assistant_id",i=a[e];if(i!=null&&String(i).trim()!==""&&String(i)!=="—")return String(i);const l=Number(a[n]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const h=async()=>{if(m.diagnosisId){p.value=!0;try{const a=await j({id:m.diagnosisId}),t=Array.isArray(a)?a:[];f.value=t}catch(a){console.error(a),f.value=[]}finally{p.value=!1}}};return D(()=>m.diagnosisId,()=>{h()},{immediate:!0}),S({refresh:h}),(a,t)=>{const e=P,n=q,i=M,l=B,C=E,I=K;return c(),u("div",H,[F((c(),L(C,{data:f.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:o(()=>[s(e,{label:"操作时间",width:"175",prop:"create_time_text"}),s(e,{label:"原医助","min-width":"120"},{default:o(({row:r})=>[_(d(b(r,"from")),1)]),_:1}),s(e,{label:"新医助","min-width":"120"},{default:o(({row:r})=>[_(d(b(r,"to")),1)]),_:1}),s(e,{label:"继承",width:"118",align:"center"},{header:o(()=>[t[0]||(t[0]=v("span",null,"继承",-1)),s(i,{placement:"top",content:"继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"},{default:o(()=>[s(n,{class:"assign-log-col-hint"},{default:o(()=>[s(N(w))]),_:1})]),_:1})]),default:o(({row:r})=>[Number(r.is_inherit)===1?(c(),u(A,{key:0},[s(l,{type:"success",size:"small"},{default:o(()=>[...t[1]||(t[1]=[_("是",-1)])]),_:1}),g(r)?(c(),u("div",O,d(g(r)),1)):V("",!0)],64)):(c(),u("span",R,"否"))]),_:1}),s(e,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:o(({row:r})=>[_(d(T(r)),1)]),_:1}),s(e,{label:"快照·业务单创建时间",width:"190"},{header:o(()=>[t[2]||(t[2]=v("span",null,"快照·业务单创建时间",-1)),s(i,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:o(()=>[s(n,{class:"assign-log-col-hint"},{default:o(()=>[s(N(w))]),_:1})]),_:1})]),default:o(({row:r})=>[_(d(k(r)),1)]),_:1}),s(e,{label:"操作人",width:"110",prop:"operator_name"}),s(e,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),s(e,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,p.value]])])}}}),U=z(Y,[["__scopeId","data-v-078f6683"]]);export{U as default};
@@ -1 +1 @@
import{o as D,R as b,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as j,v as P,K as U,L as $,aB as H,p as K,M as v,dj as c}from"./.pnpm-CyuAWpdG.js";import{ah as Y}from"./tcm-BSADvQNg.js";import{_ as q}from"./index-Bkarz3gO.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=D({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function B(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return b(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=U,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:B},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),P(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[$("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[j,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
import{o as D,R as b,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as j,v as P,K as U,L as $,aB as H,p as K,M as v,dj as c}from"./.pnpm-CyuAWpdG.js";import{ah as Y}from"./tcm-CiKToMpm.js";import{_ as q}from"./index-Bv3nGWNs.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=D({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function B(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return b(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=U,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:B},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),P(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[$("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[j,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
@@ -1 +1 @@
import{o as N,dl as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bk as L,bl as z,T as u,s as y,bn as M,M as v}from"./.pnpm-CyuAWpdG.js";import j from"./RecordingPlaybackBlock-DDHsYga7.js";import{U as x}from"./index-BJZwRQXE.js";import{j as c,_ as q}from"./index-Bkarz3gO.js";import{al as K,am as k,an as A}from"./tcm-BSADvQNg.js";import"./RecordingVideoPlayer-B0kkGDId.js";import"./file-BjbV-eNx.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
import{o as N,dl as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bk as L,bl as z,T as u,s as y,bn as M,M as v}from"./.pnpm-CyuAWpdG.js";import j from"./RecordingPlaybackBlock-2LgAgrM4.js";import{U as x}from"./index-D8BAihWm.js";import{j as c,_ as q}from"./index-Bv3nGWNs.js";import{al as K,am as k,an as A}from"./tcm-CiKToMpm.js";import"./RecordingVideoPlayer-DsWBuy0O.js";import"./file-Dca9uAzo.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
@@ -1 +1 @@
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as n,L as c,P as k,ac as z,bn as M,u as p,O as f,bl as O,T as _,F as P,bm as F,s as R,bk as j,bC as A,M as w}from"./.pnpm-CyuAWpdG.js";import{ao as q}from"./tcm-BSADvQNg.js";import{_ as H}from"./index-Bkarz3gO.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},$=t=>{b("view",t)},B=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,i=O,v=F,E=j,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:B},{default:n(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:n(()=>[a(i,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(i,{prop:"visit_no",label:"门诊号",width:"120"}),a(i,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(i,{label:"处方摘要","min-width":"180"},{default:n(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(i,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(i,{label:"状态",width:"140",align:"center"},{default:n(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:n(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),R("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:n(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(i,{label:"操作",width:"120",fixed:"right"},{default:n(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>$(s)},{default:n(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as n,L as c,P as k,ac as z,bn as M,u as p,O as f,bl as O,T as _,F as P,bm as F,s as R,bk as j,bC as A,M as w}from"./.pnpm-CyuAWpdG.js";import{ao as q}from"./tcm-CiKToMpm.js";import{_ as H}from"./index-Bv3nGWNs.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},$=t=>{b("view",t)},B=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,i=O,v=F,E=j,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:B},{default:n(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:n(()=>[a(i,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(i,{prop:"visit_no",label:"门诊号",width:"120"}),a(i,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(i,{label:"处方摘要","min-width":"180"},{default:n(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(i,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(i,{label:"状态",width:"140",align:"center"},{default:n(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:n(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),R("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:n(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(i,{label:"操作",width:"120",fixed:"right"},{default:n(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>$(s)},{default:n(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.consultation-transcripts[data-v-e51f1814]{padding:4px 0 16px}.transcript-toolbar[data-v-e51f1814],.transcript-detail-header[data-v-e51f1814]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.transcript-toolbar[data-v-e51f1814]{margin-bottom:20px}.transcript-toolbar h3[data-v-e51f1814],.transcript-detail h4[data-v-e51f1814]{margin:0;color:var(--el-text-color-primary);font-weight:600}.transcript-toolbar h3[data-v-e51f1814]{font-size:18px}.transcript-toolbar p[data-v-e51f1814],.transcript-detail-header p[data-v-e51f1814]{margin:7px 0 0;color:var(--el-text-color-secondary);line-height:1.7;font-size:13px}.transcript-error[data-v-e51f1814]{margin-bottom:16px}.transcript-body[data-v-e51f1814]{display:flex;min-height:360px;border-top:1px solid var(--el-border-color-lighter)}.transcript-sessions[data-v-e51f1814]{flex:0 0 282px;min-width:0;padding:18px 18px 0 0;border-right:1px solid var(--el-border-color-lighter)}.session-count[data-v-e51f1814]{margin:12px 0 8px;color:var(--el-text-color-secondary);font-size:12px}.session-list[data-v-e51f1814]{display:flex;flex-direction:column;gap:8px;max-height:580px;overflow-y:auto;margin-bottom:12px}.session-item[data-v-e51f1814]{display:flex;flex-direction:column;align-items:flex-start;gap:8px;width:100%;padding:13px 12px;border:1px solid var(--el-border-color-lighter);border-radius:7px;background:var(--el-bg-color);text-align:left;cursor:pointer;font:inherit}.session-item[data-v-e51f1814]:hover,.session-item.selected[data-v-e51f1814]{border-color:var(--el-color-primary);background:var(--el-color-primary-light-9)}.session-item[data-v-e51f1814]:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:-2px}.session-time[data-v-e51f1814]{color:var(--el-text-color-primary);font-size:14px;font-weight:600}.session-meta[data-v-e51f1814],.session-preview[data-v-e51f1814]{color:var(--el-text-color-secondary);font-size:12px;line-height:1.6}.session-preview[data-v-e51f1814]{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}.transcript-detail[data-v-e51f1814]{flex:1;min-width:0;padding:20px 0 0 24px}.transcript-detail h4[data-v-e51f1814]{font-size:16px;line-height:1.7}.transcript-status[data-v-e51f1814]{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:16px 0;color:var(--el-text-color-secondary);font-size:13px}.transcript-note[data-v-e51f1814]{padding:10px 12px;background:var(--el-color-warning-light-9);color:var(--el-color-warning-dark-2);font-size:13px;line-height:1.7}.transcript-text[data-v-e51f1814]{margin:0;padding:20px 0;color:var(--el-text-color-primary);white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;font-size:15px;line-height:2;-webkit-user-select:text;-moz-user-select:text;user-select:text}.transcript-empty[data-v-e51f1814]{flex:1}@media (max-width: 900px){.transcript-body[data-v-e51f1814]{flex-direction:column}.transcript-sessions[data-v-e51f1814]{flex-basis:auto;padding:16px 0;border-right:0;border-bottom:1px solid var(--el-border-color-lighter)}.session-list[data-v-e51f1814]{max-height:300px}.transcript-detail[data-v-e51f1814]{padding:20px 0 0}.transcript-detail-header[data-v-e51f1814]{flex-wrap:wrap}}

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